1. 从零手搓AI工程:为什么我不建议你直接调包
很多人一听到“AI工程”这四个字,第一反应就是打开某个云平台,拖几个组件,调一下API,然后跑通了事。我刚开始接触这个领域的时候也是这么想的,觉得底层的东西有框架、有库、有现成的模型,何必自己从头造轮子。直到有一次,线上推理服务在高峰期延迟突然从80毫秒飙到2秒,日志里全是内存溢出的报错,而我对着那堆封装好的接口完全不知道从哪下手排查。那一刻我才意识到,只会调包的人,在真正的问题面前是没有任何还手之力的。
ai-engineering-from-scratch这个方向,核心不是让你去重新发明Transformer,而是让你把AI系统里每一个关键环节都亲手实现一遍,哪怕是最粗糙的版本。只有你自己写过一遍矩阵乘法、手推过一次反向传播、手动实现过一次注意力机制,你才能在模型行为异常的时候,迅速定位到底是数据的问题、梯度的问题,还是计算图的问题。这个能力,是任何高级API都给不了你的。
这篇文章适合三类人:第一类是有一定编程基础但没接触过AI系统底层实现的开发者;第二类是用过一些AI框架但遇到性能瓶颈不知道怎么优化的工程师;第三类是准备面试AI相关岗位、需要把原理讲清楚而不是背八股文的求职者。我会从最基础的环境搭建开始,一步步带你走过数据处理、模型构建、训练循环、推理优化这几个核心环节,每个环节都会告诉你为什么这么做、不这么做会怎样,以及我在实际操作中踩过的那些坑。
需要提前说明的是,这篇文章不会涉及任何具体的商业平台推荐,也不会教你如何绕过某些限制去获取资源。所有的工具和库都是公开可获取的,所有的代码都可以在你自己的机器上复现。我尽量用最朴素的方式来讲,让你看完之后能自己动手写出一个能跑通的迷你AI系统,而不是只会复制粘贴。
2. 环境搭建:别让依赖问题成为你的第一个拦路虎
2.1 为什么我坚持用虚拟环境而不是全局安装
我见过太多人,拿到一个新项目,上来就是pip install一顿操作,结果把系统自带的Python环境搞得乱七八糟,最后连pip本身都跑不起来了。AI工程涉及的科学计算库特别多,版本之间的依赖关系极其复杂,numpy、scipy、torch这些库对底层数学库的版本要求各不相同,全局安装几乎必然导致冲突。
我的做法是,每个项目都单独建一个虚拟环境。用venv也好,用conda也好,核心原则是隔离。以venv为例,创建环境的命令很简单:
python -m venv ai-env source ai-env/bin/activate # Linux或macOS # ai-env\Scripts\activate # Windows创建完之后,第一件事不是急着装库,而是先升级pip和setuptools。这一步很多人会忽略,但老版本的pip在解析复杂依赖时经常出问题,升级一下能省掉后面很多麻烦:
pip install --upgrade pip setuptools wheel接下来装库的时候,我强烈建议先装numpy,再装其他。因为很多科学计算库在安装时都需要编译,而编译过程依赖numpy的头文件。如果你先装了torch再装numpy,有时候会出现版本不匹配导致torch无法正常导入的情况。这个顺序问题我在至少三个不同的项目里遇到过,每次都是重装环境才解决。
提示:如果你用的是Apple Silicon的机器,某些库的预编译包可能不完整,需要从源码编译。这时候建议先安装Xcode Command Line Tools,否则编译过程会报缺少头文件的错误。
2.2 硬件资源的合理评估与选择
AI工程对硬件的要求取决于你做到什么程度。如果你只是想做一个小型的全连接网络来理解原理,CPU完全够用,甚至不需要独立显卡。但如果你想跑稍微大一点的模型,比如带卷积层的图像分类网络,那最好还是有一块支持CUDA的显卡。
这里有一个很多人会犯的错误:盲目追求大显存。我见过有人为了跑一个参数量只有几百万的模型,去买了一张显存超大的卡,结果发现训练速度并没有提升多少,因为瓶颈根本不在显存上,而在数据加载和CPU预处理上。我的建议是,先用手头的设备把流程跑通,确认瓶颈在哪里,再决定要不要升级硬件。
如果你没有独立显卡,也不用灰心。现在很多云平台都提供按小时计费的GPU实例,价格并不贵。但我要提醒一句,用云实例的时候一定要注意数据安全,不要把敏感数据传上去。另外,用完记得及时关闭实例,我有一次忘了关,第二天醒来发现账单多了一笔不小的数目,这个教训希望大家引以为戒。
对于纯CPU环境,我建议把torch的线程数设置成物理核心数,而不是逻辑核心数。因为超线程在矩阵运算中带来的收益非常有限,反而可能因为缓存竞争导致性能下降。设置方法是在代码开头加上:
import torch torch.set_num_threads(8) # 根据你的物理核心数调整2.3 目录结构的设计:一开始就规范,后面才不痛苦
很多教程不讲目录结构,上来就写代码,结果项目稍微大一点就乱成一锅粥。我在经历了多次重构之后,总结出了一套比较顺手的目录组织方式:
project/ ├── data/ # 原始数据和预处理后的数据 │ ├── raw/ │ └── processed/ ├── src/ # 源代码 │ ├── data/ # 数据加载和预处理模块 │ ├── models/ # 模型定义 │ ├── training/ # 训练循环和评估逻辑 │ └── utils/ # 通用工具函数 ├── configs/ # 配置文件 ├── experiments/ # 实验记录和模型检查点 ├── notebooks/ # 探索性分析 └── requirements.txt # 依赖清单这个结构的好处是,数据和代码分离,实验记录和源代码分离。当你需要复现某个实验结果时,只要找到对应的配置文件和检查点就行,不会因为代码改动而丢失历史信息。我特别建议把每次实验的超参数、数据集版本、代码提交哈希都记录在一个JSON文件里,存到experiments目录下。这个习惯在我后来做模型对比的时候帮了大忙,因为人的记忆是不可靠的,三个月后你绝对记不清当时用的是哪个学习率。
3. 数据处理:AI系统里最容易被低估的环节
3.1 为什么说数据质量决定了模型的上限
我刚开始做AI项目的时候,把80%的精力花在调模型结构上,结果折腾了两周,准确率死活上不去。后来一位前辈看了一眼我的训练数据,说了一句话让我印象深刻:“你这数据里有一半的标签都是错的,模型能学对才怪。”我回去一检查,发现数据标注环节确实出了问题,有些样本的标签和内容完全对不上。
从那以后,我养成了一个习惯:拿到任何数据集,第一件事不是急着喂给模型,而是先做数据质量检查。具体来说,我会看这几个方面:
- 标签分布:各个类别的样本数量是否均衡?如果某个类别的样本特别少,模型很可能会忽略这个类别。
- 重复样本:有没有完全相同的样本出现在训练集和验证集中?这种情况会导致验证集准确率虚高,实际部署时性能暴跌。
- 异常值:数值型特征有没有超出合理范围的极端值?这些值会干扰模型的梯度计算。
- 缺失值:缺失比例是多少?缺失是随机的还是有规律的?这决定了你是该删除、填充还是用模型预测。
我通常会写一个简单的脚本,把上面这些统计量算出来,存成一个报告。这个报告在项目交接的时候特别有用,别人一看就知道数据的基本情况。
3.2 手写数据加载器:理解每一个批次的来龙去脉
虽然现在各种框架都提供了现成的DataLoader,但我还是建议你至少手写一次数据加载的逻辑。原因很简单,只有你自己实现过,才能理解batch_size、shuffle、num_workers这些参数到底在干什么,以及它们对训练过程有什么影响。
一个最基础的数据加载器大概长这样:
import numpy as np class SimpleDataLoader: def __init__(self, features, labels, batch_size=32, shuffle=True): self.features = features self.labels = labels self.batch_size = batch_size self.shuffle = shuffle self.indices = np.arange(len(features)) def __iter__(self): if self.shuffle: np.random.shuffle(self.indices) for start in range(0, len(self.indices), self.batch_size): end = min(start + self.batch_size, len(self.indices)) batch_idx = self.indices[start:end] yield self.features[batch_idx], self.labels[batch_idx] def __len__(self): return (len(self.indices) + self.batch_size - 1) // self.batch_size这段代码看起来很简单,但里面有几个细节值得注意。第一,shuffle是在每个epoch开始时重新打乱索引,而不是打乱数据本身,这样可以避免复制数据带来的内存开销。第二,最后一个批次可能不足batch_size,用min函数处理边界情况。第三,__len__方法用的是向上取整,确保不会漏掉最后一个不完整的批次。
我在实际使用中发现,如果数据量比较大,把所有数据一次性加载到内存里可能会爆内存。这时候就需要实现一个惰性加载的版本,每次只读取当前批次需要的数据。对于图像数据,我通常会先把图片路径存下来,在__iter__里根据路径读取图片并做预处理。这样做虽然会增加一些I/O开销,但内存占用会小很多。
3.3 特征工程:把原始数据变成模型能消化的形式
原始数据往往不能直接喂给模型。比如文本数据需要转成数字索引,图像数据需要归一化,类别特征需要做独热编码。这些转换步骤统称为特征工程。
我见过很多人把特征工程和模型训练混在一起写,结果代码又长又乱,改一个地方就牵一发而动全身。我的做法是把特征工程独立成一个模块,输入是原始数据,输出是模型可以直接使用的张量。这个模块的接口要固定,这样后面换模型的时候,数据处理部分完全不用动。
以文本分类为例,一个典型的特征工程流程包括:分词、构建词表、把词转成索引、截断或填充到固定长度。每一步我都会单独写一个函数,方便测试和复用。特别是词表的构建,一定要在训练集上做,然后用同一个词表处理验证集和测试集。如果分别在各自的数据上构建词表,会导致索引不一致,模型完全无法工作。这个坑我在早期项目中踩过,当时排查了很久才发现是词表的问题。
注意:处理类别特征的时候,如果某个类别的出现频率极低,可以考虑把它归到“其他”类别里。这样做可以减少特征维度,同时避免模型在稀有类别上过拟合。
4. 模型构建:从矩阵乘法到完整网络
4.1 手写一个全连接层:理解权重和偏置的作用
现在让我们从最基础的全连接层开始。一个全连接层的核心计算就是矩阵乘法加上偏置项,然后通过激活函数。用numpy实现的话,前向传播大概是这样:
import numpy as np class LinearLayer: def __init__(self, input_dim, output_dim): # 使用He初始化,适合ReLU激活函数 self.weight = np.random.randn(input_dim, output_dim) * np.sqrt(2.0 / input_dim) self.bias = np.zeros(output_dim) self.input_cache = None def forward(self, x): self.input_cache = x return np.dot(x, self.weight) + self.bias def backward(self, grad_output): # 计算权重梯度 grad_weight = np.dot(self.input_cache.T, grad_output) # 计算偏置梯度 grad_bias = np.sum(grad_output, axis=0) # 计算输入梯度,传递给上一层 grad_input = np.dot(grad_output, self.weight.T) return grad_input, grad_weight, grad_bias这里有几个关键点需要解释。第一,权重的初始化方式很重要。如果全部初始化为零,所有神经元的输出都一样,反向传播时梯度也相同,网络永远学不到东西。如果初始化得太大,激活值会饱和,梯度接近零,同样学不动。He初始化是根据输入维度来缩放随机初始化的幅度,让每一层的输出方差保持稳定。
第二,input_cache的作用是在反向传播时复用前向传播的输入。如果没有缓存,反向传播时就需要重新计算或者额外传入,效率会降低。这个技巧在实现更复杂的层时同样适用。
第三,偏置的梯度是对批次维度求和,因为偏置对每个样本的影响是相同的,所以梯度要累加起来。这个细节很多人第一次实现的时候会搞错,导致偏置更新方向不对。
4.2 激活函数的选择:ReLU不是万能的
激活函数给神经网络引入了非线性,没有它,再深的网络也等价于一个线性变换。ReLU因为计算简单、梯度不饱和,成为了最常用的选择。但它有一个致命的问题:当输入小于零时,梯度为零,神经元可能“死亡”,再也无法更新。
我在一个项目中遇到过这种情况:训练到一半,损失突然不下降了,检查发现有一半的神经元输出恒为零。后来把激活函数换成LeakyReLU,给负半轴一个很小的斜率,问题就解决了。LeakyReLU的实现很简单:
class LeakyReLU: def __init__(self, negative_slope=0.01): self.negative_slope = negative_slope self.input_cache = None def forward(self, x): self.input_cache = x return np.where(x > 0, x, self.negative_slope * x) def backward(self, grad_output): grad_input = grad_output.copy() grad_input[self.input_cache <= 0] *= self.negative_slope return grad_input除了LeakyReLU,还有ELU、GELU等变体,各有各的适用场景。我的经验是,对于深层网络,GELU通常效果更好,但计算量稍大;对于浅层网络或者需要极致推理速度的场景,ReLU或LeakyReLU就够了。选择哪个,取决于你的具体需求和硬件条件。
4.3 损失函数:模型到底在优化什么
损失函数定义了模型要最小化的目标。分类任务常用交叉熵损失,回归任务常用均方误差。这里我想重点讲一下交叉熵,因为它在分类任务中太重要了,但很多人只是调用现成的API,并不理解它背后的含义。
交叉熵衡量的是模型预测的概率分布和真实分布之间的差异。对于二分类问题,公式是:
L = -[y * log(p) + (1 - y) * log(1 - p)]其中y是真实标签(0或1),p是模型预测为正类的概率。当y=1时,损失是-log(p),模型预测的概率越接近1,损失越小;当y=0时,损失是-log(1-p),模型预测的概率越接近0,损失越小。
手动实现交叉熵的时候,有一个数值稳定性的问题需要注意。如果p非常接近0或1,log(p)会变成负无穷,导致数值溢出。解决方案是在log里面加一个极小值:
def cross_entropy_loss(y_true, y_pred, epsilon=1e-12): y_pred = np.clip(y_pred, epsilon, 1 - epsilon) return -np.mean(y_true * np.log(y_pred) + (1 - y_true) * np.log(1 - y_pred))这个epsilon看起来不起眼,但没有它,训练过程随时可能因为一个极端预测值而崩溃。我在实际项目中至少遇到过两次因为这个问题导致的训练中断,每次都是加上epsilon之后就好了。
5. 训练循环:让模型真正学起来
5.1 前向传播与反向传播的完整串联
有了层和损失函数,接下来就是把它们串起来,形成一个完整的训练循环。一个最朴素的训练循环包括:前向传播计算输出、计算损失、反向传播计算梯度、更新参数。用前面实现的组件,大概是这样:
def train_step(model, x_batch, y_batch, learning_rate): # 前向传播 activations = [x_batch] for layer in model.layers: activations.append(layer.forward(activations[-1])) # 计算损失 loss = cross_entropy_loss(y_batch, activations[-1]) # 反向传播 grad = (activations[-1] - y_batch) / len(y_batch) # 交叉熵+softmax的梯度 for i in range(len(model.layers) - 1, -1, -1): grad, grad_w, grad_b = model.layers[i].backward(grad) model.layers[i].weight -= learning_rate * grad_w model.layers[i].bias -= learning_rate * grad_b return loss这里有一个简化:我假设最后一层是softmax激活,并且损失是交叉熵,所以梯度可以直接写成(预测值 - 真实值) / batch_size。这个结论是数学推导出来的,实际使用时可以放心。但如果你用的损失函数或激活函数不同,这个梯度公式就不适用了,需要重新推导。
反向传播的顺序是从最后一层往前,每一层接收来自后一层的梯度,计算自己参数的梯度,然后把输入梯度传给前一层。这个过程中,梯度的形状必须严格匹配,否则矩阵乘法会报错。我在调试的时候经常用print把每一层的梯度形状打出来,确认无误后再继续。
5.2 学习率的选择:太大震荡,太小蜗牛
学习率是训练过程中最重要的超参数之一。太大,损失会剧烈震荡甚至发散;太小,训练速度慢得让人抓狂。我通常的做法是先用一个中等大小的学习率(比如0.01)跑几百步,观察损失曲线的形状。如果损失下降很慢,就适当增大;如果损失上下跳动,就减小。
但手动调学习率很费时间,更好的办法是使用学习率预热和衰减策略。预热就是在训练初期用很小的学习率,逐渐增加到设定值,这样可以避免初期梯度不稳定导致的发散。衰减则是在训练后期逐渐减小学习率,让模型更精细地收敛。
一个简单的指数衰减实现:
def exponential_decay(initial_lr, decay_rate, decay_steps, global_step): return initial_lr * (decay_rate ** (global_step / decay_steps))我一般会把decay_rate设为0.96,decay_steps设为1000步。这样每过1000步,学习率乘以0.96,训练结束时学习率大概是初始值的十分之一左右。这个策略在大多数任务上都能工作得不错。
5.3 过拟合的识别与应对:早停、正则化、Dropout
过拟合是训练过程中最常见的问题。表现是训练集损失持续下降,但验证集损失先降后升。识别过拟合很简单,只要同时记录训练和验证的损失曲线就行。应对过拟合的手段有好几种,我按使用频率排序:
第一是早停。当验证集损失连续若干个epoch没有改善时,就停止训练,保存验证集损失最低的那个模型。这个策略简单有效,几乎不增加计算成本。我通常把耐心值设为5到10个epoch,具体取决于数据集大小和训练速度。
第二是L2正则化。在损失函数里加上权重的平方和乘以一个系数,迫使权重保持较小的值。实现起来就是在计算梯度时,给权重梯度加上lambda * weight:
grad_w += weight_decay * model.layers[i].weightweight_decay一般设为1e-4到1e-2之间。太大的话模型会欠拟合,太小则起不到正则化的效果。
第三是Dropout。在训练时随机将一部分神经元的输出置零,迫使网络不依赖特定的神经元。实现上就是在激活函数之后加一个掩码:
def dropout(x, drop_rate, training=True): if not training: return x mask = np.random.binomial(1, 1 - drop_rate, size=x.shape) / (1 - drop_rate) return x * mask注意这里除以了1 - drop_rate,是为了保持输出的期望值不变。这个细节在推理时特别重要,如果不做这个缩放,训练和推理时的输出分布会不一致,导致性能下降。
6. 推理与部署:让模型真正产生价值
6.1 推理模式的切换:BatchNorm和Dropout的处理
训练好的模型在推理时,有些层的行为需要改变。最典型的是Dropout,推理时不应该再随机丢弃神经元,而是使用完整的网络。另外,如果用了BatchNorm,推理时要用训练阶段累积的均值和方差,而不是当前批次的统计量。
我在第一次部署模型的时候就犯过这个错误,忘了切换Dropout的状态,导致同一个输入每次推理的结果都不一样,排查了半天才发现问题。所以现在我的习惯是,在模型类里显式定义一个eval方法,把所有需要切换状态的层都处理一遍:
class Model: def eval(self): self.training = False for layer in self.layers: if hasattr(layer, 'training'): layer.training = False def train(self): self.training = True for layer in self.layers: if hasattr(layer, 'training'): layer.training = True这个模式在PyTorch等框架里也是类似的,model.eval()和model.train()就是做这个事情。理解了这个机制,你就知道为什么有时候推理结果和训练时的验证结果对不上了。
6.2 模型量化:用精度换速度的取舍
当模型需要部署到资源受限的设备上时,量化是一个常用的优化手段。简单来说,就是把模型参数从32位浮点数转换成8位整数,这样模型大小减少到原来的四分之一,推理速度也能提升两三倍。但代价是精度会有所下降。
量化的核心是找到一个映射关系,把浮点数范围映射到整数范围。最简单的是线性量化:
def quantize(tensor, num_bits=8): min_val = tensor.min() max_val = tensor.max() scale = (max_val - min_val) / (2 ** num_bits - 1) zero_point = -min_val / scale quantized = np.round(tensor / scale + zero_point).astype(np.int32) return quantized, scale, zero_point def dequantize(quantized, scale, zero_point): return (quantized.astype(np.float32) - zero_point) * scale实际使用中,量化后的模型需要重新在验证集上评估精度。如果精度下降太多,可以考虑只量化部分层,或者使用更复杂的量化方案。我的经验是,对于大多数分类任务,8位量化带来的精度损失通常在1%以内,完全可以接受。
6.3 推理性能的瓶颈定位:别猜,去测
推理速度慢的时候,很多人第一反应是模型太大,于是去压缩模型。但根据我的经验,瓶颈往往不在模型本身,而在数据预处理或者后处理上。我遇到过一个案例,模型推理只花了10毫秒,但图像解码和缩放花了50毫秒,整体延迟的大头根本不在模型上。
定位瓶颈的方法很简单,在代码的关键节点打上时间戳,把每个阶段的耗时都记录下来:
import time def inference_pipeline(input_data): t0 = time.time() processed = preprocess(input_data) t1 = time.time() output = model.forward(processed) t2 = time.time() result = postprocess(output) t3 = time.time() print(f"预处理: {(t1-t0)*1000:.2f}ms") print(f"模型推理: {(t2-t1)*1000:.2f}ms") print(f"后处理: {(t3-t2)*1000:.2f}ms") return result这个简单的计时方法帮我省下了大量盲目优化的时间。有一次我发现预处理占了总时间的70%,于是把图像缩放从双三次插值改成双线性插值,速度立刻提升了一倍,而精度几乎没有变化。
提示:测量推理时间的时候,一定要先跑几次预热,让CPU缓存和GPU显存都进入稳定状态,否则第一次推理的时间会明显偏长,导致误判。
7. 那些只有踩过才知道的坑
7.1 梯度消失与梯度爆炸:深层网络的噩梦
当网络层数比较多的时候,反向传播的梯度在逐层传递过程中会不断乘以权重矩阵。如果权重矩阵的奇异值小于1,梯度会指数级衰减,这就是梯度消失;如果大于1,梯度会指数级增长,这就是梯度爆炸。
梯度消失的表现是,靠近输入的层参数几乎不更新,模型只能学到浅层的特征。梯度爆炸的表现是,损失突然变成NaN,训练完全崩溃。我在训练一个20层的全连接网络时,同时遇到了这两个问题,前几层梯度接近零,后几层梯度大到溢出。
解决方案有几个。对于梯度爆炸,最直接的是梯度裁剪,把梯度的范数限制在一个阈值以内:
def clip_gradients(grads, max_norm=1.0): total_norm = np.sqrt(sum(np.sum(g ** 2) for g in grads)) if total_norm > max_norm: scale = max_norm / total_norm grads = [g * scale for g in grads] return grads对于梯度消失,可以使用残差连接,让梯度有一条直接的通路绕过某些层。另外,BatchNorm也能缓解这个问题,因为它把每一层的输入重新标准化,避免了激活值分布的大幅偏移。
7.2 随机种子的重要性:可复现性是工程的基础
AI实验里充满了随机性:权重初始化、数据打乱、Dropout掩码。如果不固定随机种子,每次运行的结果都不一样,你根本无法判断模型的变化是因为改了代码还是因为随机波动。
我的做法是在程序入口处固定所有相关的随机种子:
import random import numpy as np def set_seed(seed=42): random.seed(seed) np.random.seed(seed) # 如果用了其他框架,也要设置对应的种子但要注意,固定种子并不能保证完全可复现,因为某些操作的并行执行顺序可能不同。不过至少能保证大部分情况下结果是一致的。我在做模型对比实验时,会跑三次不同的种子,取平均值和标准差,这样结论更可靠。
7.3 内存泄漏:训练越久内存越少是怎么回事
Python的垃圾回收机制在处理循环引用时有时会失效,导致内存泄漏。在训练循环中,如果每次迭代都创建新的计算图而没有释放,内存会持续增长,最终导致程序崩溃。
我遇到过一次,训练到第50个epoch时内存爆了,但前49个epoch都正常。排查后发现是在验证阶段累积了损失值,但没有及时清空列表。这种问题很隐蔽,因为短期内看不出影响。
预防内存泄漏的方法包括:避免在循环中创建不必要的全局变量,及时删除不再使用的大对象,使用gc.collect()手动触发垃圾回收。另外,如果用的是PyTorch,要注意在验证阶段使用torch.no_grad()上下文管理器,避免构建计算图。
8. 从手搓到工程化:下一步该往哪走
把上面这些环节都亲手实现一遍之后,你对AI系统的理解会完全不一样。你会发现,那些框架帮你做的事情,本质上并不神秘,只是一些工程上的封装和优化。当你再遇到性能问题或者奇怪的行为时,你会有清晰的排查思路,而不是对着黑盒干瞪眼。
接下来,你可以往几个方向深入。一是性能优化,学习如何用向量化操作替代循环,如何利用GPU的并行计算能力,如何用混合精度训练加速。二是模型压缩,研究剪枝、蒸馏、量化这些技术,让模型在保持精度的同时变得更小更快。三是工程化部署,学习如何把模型封装成服务,如何处理并发请求,如何做版本管理和灰度发布。
但无论往哪个方向走,手搓一遍的经历都会是你最扎实的底子。我在带新人的时候,总是先让他们用numpy实现一个完整的训练流程,哪怕只是在一个很小的数据集上。这个过程可能有点枯燥,但走完之后,他们看框架源码的能力会明显提升,遇到问题也不再慌张了。
最后分享一个我自己的习惯:每学一个新的AI概念,我都会尝试用最基础的numpy实现一个最小可运行的版本。这个版本可能很粗糙,可能很慢,但它是我理解这个概念的基石。有了这个基石,再去用高级框架的时候,我就知道每一行代码背后在发生什么,调参的时候也有方向感。这个习惯坚持了几年,让我在面对各种新模型、新架构的时候,都能快速抓住本质,而不是被表面的复杂度吓住。