直接说吧,这篇是TensorFlow学习之路的第二篇。上一篇我们把环境、基本张量操作、自动求导这些地基打完了,这一篇就该上手第一个真正的模型了。我会用最直白的方式把神经网络(ANN)从概念讲到代码落地,全程用TensorFlow的Keras接口实现,不整那些花里胡哨的底层API,适合刚摸到深度学习门槛、想快点看到模型跑起来的人。
我尽量把“为什么这么做”讲清楚,而不是丢一段能跑的代码让你复制就完事。毕竟你现在把模型跑通了,下个月遇到一个新问题,换个数据集,如果不懂背后的逻辑,照样寸步难行。
1. 神经网络到底在解决什么问题
1.1 机器学习是一场“猜函数”的游戏
先抛开神经网络那些吓人的术语。深度学习的本质,是让计算机自己找一个函数“映射关系”:你给我一堆输入,我告诉你输出。比如输入是房屋面积、楼层、朝向,输出是房价;输入是一张图片的像素点,输出是“猫”还是“狗”。
传统的编程方式是人来写这条规则:如果面积大于100且楼层在10层以上,价格就是多少。但真实世界里的规则太复杂,根本没法用if-else穷举,于是我们换了个思路——不写规则,只准备大量的“输入-输出”例子,让模型自己从例子里归纳规则。这个过程就叫训练。
所以你可以把神经网络理解成一个极其灵活的“万能函数拟合器”,它内部有成千上万个参数(权重和偏置),训练的过程就是不断调整这些参数,让模型在已知数据上的预测结果尽量接近真实答案。
1.2 ANN和“深度学习”到底是什么关系
ANN全称Artificial Neural Network,人工神经网络。它是深度学习最基础的形态,我们常说的深度神经网络(DNN)本质上就是一种层数较多的ANN。ANN的基本结构就三样东西:输入层、隐藏层、输出层。
输入层负责接收原始数据,比如一张28x28的图片可以拉平成784个像素值;隐藏层负责对数据进行层层加工、抽象,提取出越来越高级的特征;输出层给出最终预测结果。层与层之间的每个神经元都通过“权重”连接,权重的大小就代表前一层某个神经元对后一层某个神经元的影响程度。
我一直觉得把ANN比作一条流水线特别贴切:原材料(原始数据)从第一个工位(输入层)进去,每个工位(隐藏层神经元)都做一点加工(加权求和并激活),最后在包装工位(输出层)得出成品(预测结果)。如果加工工序够多够合理,就算是很复杂的原材料也能做出想要的东西。
2. 手工拆解一个神经元:感知机与激活函数
2.1 单个神经元的工作过程
神经网络的基本单元叫神经元,学术上叫感知机。它的工作流程其实特别简单,四步走:
- 接收来自上一层神经元的输入值 x1, x2, x3…
- 每个输入分别乘以对应的权重 w1, w2, w3…,再求和
- 加上一个偏置值 b(相当于给神经元一个初始的“偏向性”)
- 把求和结果送入一个激活函数,得到最终输出
数学表达式就是 y = activation(w1x1 + w2x2 + w3*x3 + b)。
前面那部分线性加权求和,模型能表达的空间是有限的,它只能画出一条直线来区分数据。但真实世界的数据哪儿有那么规整,基本都是弯弯曲曲的边界。激活函数的作用,就是往这个线性结果里注入“非线性”,让模型有能力拟合弯曲的边界。
我举个具体场景:假设你想判断一个人是否适合买某款保险,输入是年龄、收入、健康分三个特征。如果没有激活函数,模型只能学到一个线性的加减规则,比如“年龄乘0.3加收入乘0.2再减健康分乘0.1,大于某个数就买”。但现实里往往是“年龄太大太小都不行,收入中等偏好最好”这种非单调的复杂规律,这时候没有非线性的激活函数,模型就算把参数调到天荒地老也学不出来。
2.2 激活函数怎么选
常见的激活函数就那几个,但选哪个很有讲究,我用一张表总结一下:
| 激活函数 | 输出范围 | 优点 | 缺点 | 常用位置 |
|---|---|---|---|---|
| Sigmoid | 0到1 | 输出可解释为概率,平滑 | 容易梯度消失,输出非零中心 | 二分类输出层 |
| Tanh | -1到1 | 零中心,比Sigmoid强 | 仍有梯度消失问题 | 全连接隐藏层(早期) |
| ReLU | 0到正无穷 | 计算快,缓解梯度消失 | 神经元可能“坏死” | 隐藏层首选 |
| Softmax | 0到1且和为1 | 多分类概率分布 | 依赖输入尺度 | 多分类输出层 |
隐藏层现在基本不用Sigmoid和Tanh了,ReLU是默认选项。原因很简单:Sigmoid在输入值绝对值比较大的时候,导数趋近于0,误差信号传不回去,网络就练不动了。ReLU在正区间导数恒为1,梯度能顺畅地往回传,训练速度快很多。
不过ReLU有个坑,叫“神经元坏死”。如果某个神经元的加权求和结果一直是负数,ReLU输出永远为0,它的梯度也永远是0,这个神经元就再也激活不了。解决办法可以换Leaky ReLU这类变体,但实际项目里标准ReLU配合合理的学习率设置,绝大多数情况都够用,不用过度纠结。
3. 用TensorFlow落地一个ANN
3.1 准备一份练手数据集
理论说完了,必须上手写代码。我选一个图像分类任务:手写数字识别。数据集自带60000张训练图片和10000张测试图片,每张图是28x28的灰度图,代表数字0-9。任务是训练一个模型,输入图片像素,输出它是哪个数字。
这个任务在深度学习里的地位,基本等同于编程界的“Hello World”,足够简单又不失代表性。
首先是加载数据,TensorFlow里直接一行代码就搞定。如果你本地网络不稳定,数据集下载可能会卡住,建议提前准备好本地副本。
import tensorflow as tf # 加载内置数据集 (x_train, y_train), (x_test, y_test) = tf.keras.datasets.mnist.load_data() print("训练集形状:", x_train.shape) # (60000, 28, 28) print("测试集形状:", x_test.shape) # (10000, 28, 28) print("标签示例:", y_train[:10]) # [5 0 4 1 9 2 1 3 1 4]你会看到训练数据是一个三维数组:6万张图,每张图是28行28列的二维像素矩阵。像素值范围在0到255之间,255代表白色,0代表黑色。
3.2 从零定义一个两隐藏层的ANN
数据准备好了,接下来构建模型。我用Keras的Sequential模型,一层一层地堆叠,最直观也最适合新手理解。
# 构建模型 model = tf.keras.Sequential([ # 输入层:把28x28的图片拉平成784个特征 tf.keras.layers.Flatten(input_shape=(28, 28)), # 第一个隐藏层:128个神经元,ReLU激活 tf.keras.layers.Dense(128, activation='relu'), # 第二个隐藏层:64个神经元,ReLU激活 tf.keras.layers.Dense(64, activation='relu'), # 输出层:10个神经元,对应0-9十个数字 tf.keras.layers.Dense(10, activation='softmax') ]) # 查看模型结构 model.summary()先看Flatten层,它只是把数据“拉直”,28x28变成784,不参与计算。然后是两个Dense全连接层,Dense的参数第一个是神经元数量,第二个是激活函数。我故意用了128和64这样常见的配置,给大家一个直观感知——层数不用多,神经元数量也不用贪大,这个量级处理手写数字绰绰有余。
输出层的10个神经元对应10个类别,用softmax激活,输出的是一个概率分布。比如一张图片经过模型后输出一个长度为10的向量,每个位置的值代表这张图属于对应数字的概率,10个值加起来等于1,概率最大的索引就是模型的预测结果。
3.3 编译、训练与评估
模型定义完,需要先“编译”,这一步是配置学习过程。这里初学者最容易懵,我拆开讲:
# 编译模型 model.compile( optimizer='adam', # 优化器 loss='sparse_categorical_crossentropy', # 损失函数 metrics=['accuracy'] # 关注指标 ) # 训练模型 history = model.fit( x_train, y_train, epochs=10, # 遍历整个训练集10次 batch_size=32, # 每次喂32张图 validation_split=0.2, # 从训练集分出20%做验证集 verbose=1 )optimizer(优化器)是“怎么调整参数”的策略。adam可以说是目前最省心的默认选择,它自带自适应学习率,对新手极其友好,基本不用调参就能有个不错的效果。
loss(损失函数)是“预测得有多差”的度量。因为我们的标签是0-9的整数,属于多分类问题,所以用sparse_categorical_crossentropy。注意那个sparse前缀,意思是标签是整数直接用,如果你的标签是one-hot编码的向量,就要用不带sparse的categorical_crossentropy。这两个名字是新手最容易搞混的地方。
训练过程中的verbose参数建议设成1,能实时看到每一轮训练结束后的损失和准确率。epochs设为10,batch_size设为32,这两个参数是训练时的核心超参数,后文我会详细讲它们的影响。
训练完成后用测试集评估:
test_loss, test_acc = model.evaluate(x_test, y_test) print("测试准确率:", test_acc)第一次跑这个流程,测试准确率应该能到97%以上。你都没做任何图像预处理,也没精心调参,就这么简单粗暴几层全连接就能达到这个效果,可见手写数字识别任务本身相对简单,同时说明ANN确实具备了一定的特征提取能力。
4. 训练过程中的几个关键细节
4.1 数据归一化:最容易忽略却最关键的一步
我故意在上面的代码里跳过了归一化。如果你直接跑,准确率大概也能到90%出头,但收敛速度明显慢很多,而且最终的准确率上限会被压住。如果我把像素值归一化到0到1之间,效果立刻不一样。
# 归一化:把像素值从0-255压缩到0-1 x_train = x_train / 255.0 x_test = x_test / 255.0为什么这一步这么重要?因为神经网络的参数更新依赖梯度的大小,而梯度的大小跟输入数据的尺度直接相关。当输入值是几百这种大数时,加权求和的结果会非常大,经过激活函数后容易落在饱和区,梯度变得极小,参数几乎推不动。把数据缩放到0-1后,神经元的工作区间落在激活函数的敏感区域,梯度传递顺畅,模型学得又快又好。
打个比方:你量身高用厘米、量体重用千克,这两个数字的数值范围差很多。如果直接把原始数值喂给模型,体重的数值天然比身高大,模型会误以为体重这个特征更重要,分配更多权重给它。归一化就是让所有特征站在同一条起跑线上。
这个操作还有一个更高级的版本叫标准化(Standardization),公式是 (x - mean) / std,让每个特征的均值是0、方差是1。归一化的取值范围控制在0-1,标准化的分布中心在0,各有适用场景。但绝大多数图像任务,直接除以255就够了。
4.2 损失函数和优化器:你必须理解的两个核心
我前面提到loss是衡量“预测有多差”,这个说法比较粗。准确地说,它定义了一个“距离”:预测的概率分布和真实标签的距离。模型训练的目标就是不断调整参数,让这个距离越来越小。
损失函数的选择跟任务类型强相关。回归问题(预测房价、温度这种连续值)用均方误差MSE;二分类用binary_crossentropy;多分类就是前面用的sparse_categorical_crossentropy。选错了损失函数,模型要么训练不动,要么输出的结果让你看不懂。
优化器则是“沿着哪个方向、走多大步”来减小损失。adam的内部机制比较复杂,它综合了动量法和自适应学习率的思路,能够根据每个参数的历史梯度动态调整步长。所以相比传统的梯度下降,adam收敛更快、更稳,对学习率的设置也更宽容。
我还是建议你亲手试一下用不同的损失函数和优化器组合,感受它们的区别。有对比才有体会,比如你用均方误差跑分类任务,Loss会降低特别慢,准确率也上不去,踩过一次坑就明白“正确的工具做正确的事”这有多重要了。
4.3 从绘制训练曲线读模型状态
fit函数会返回一个History对象,里面有每一轮训练后在训练集和验证集上的loss和accuracy。把这些数据画出来,是判断模型状态最直观的手段。
import matplotlib.pyplot as plt # 训练曲线可视化 plt.plot(history.history['loss'], label='训练集损失') plt.plot(history.history['val_loss'], label='验证集损失') plt.legend() plt.show()训练曲线至少要会看三种情况:
第一种,训练集loss和验证集loss同步下降,最后都趋于平缓,这是最理想的“过拟合”状态,模型学得很好。
第二种,训练集loss持续下降,但验证集loss下降到某一轮后开始反弹上升,这是过拟合的信号——模型把训练集上的细节背得太死,丧失了泛化能力。
第三种,两种loss都高居不下,甚至震荡起伏,说明模型根本没学好,可能要回过去查学习率是否太大、数据是否需要预处理、网络结构是否不合理。
我现在每跑一个模型都会顺手把训练曲线画出来,这个习惯帮我排查了太多问题。光看最终测试准确率,你根本不知道中间发生了什么。
5. 常见问题与排查手册
5.1 Loss不下降或者乱跳
模型训练了好几轮,Loss纹丝不动,或者像过山车一样忽高忽低。这时候按照下面的顺序排查:
| 排查步骤 | 具体操作 | 原因 |
|---|---|---|
| 1. 检查数据预处理 | 确认归一化/标准化是否做了 | 数据尺度不一致导致训练不稳定 |
| 2. 降低学习率 | 把Adam默认学习率从0.001降到0.0001 | 学习率太大导致参数来回震荡 |
| 3. 简化模型 | 减少隐藏层神经元数量 | 模型太复杂导致梯度异常 |
| 4. 检查标签 | 确认y_train的标签值在合法范围内 | 标签错误会让loss直接异常 |
我在实际调试时发现,新手最常犯的错误是不做归一化就直接训练,尤其是输入数据范围特别大的时候,Loss输出经常是nan(Not a Number)。一旦出现nan,第一反应该去查数据预处理,而不是调网络结构。
5.2 过拟合了怎么办
验证集准确率上不去、测试集表现明显差于训练集,这就是过拟合的典型症状。我提供一个自用的“三步疗法”:
第一步,增加训练数据量。这是最朴素但往往最有效的方法,数据不够的时候一切正则化手段都是亡羊补牢。
第二步,加Dropout层。原理极其简单粗暴:训练时随机让一部分神经元“失活”,强迫网络不依赖某几个特定的神经元,从而提高泛化能力。
model = tf.keras.Sequential([ tf.keras.layers.Flatten(input_shape=(28, 28)), tf.keras.layers.Dense(128, activation='relu'), tf.keras.layers.Dropout(0.3), # 随机丢弃30%的神经元 tf.keras.layers.Dense(64, activation='relu'), tf.keras.layers.Dropout(0.3), tf.keras.layers.Dense(10, activation='softmax') ])第三步,加L2正则化,即在损失函数上追加一个权重的平方惩罚项,让模型不敢把权重学得太大。Keras里直接在Dense层加kernel_regularizer参数就行。
5.3 验证集loss一直比训练集高
这个现象很多新手会紧张,以为模型出问题了。实际上轻微的验证集loss高于训练集是完全正常的,因为模型本来就在训练集上“见过答案”,自然会考得更好一点。但如果差距越来越大,就要警惕过拟合,参考5.2的解法。
反过来还有一种离奇情况:验证集loss比训练集还低。这并不代表模型更好,往往是因为验证集分布恰好比训练集更“容易”,或者是Dropout在训练时发挥了随机失活的“拖后腿”作用,导致训练时表现被拉低了,而验证时Dropout关闭了。这同样正常,不用太纠结。
5.4 新手最容易踩的TensorFlow环境坑
代码逻辑都对了,但一跑就报错,十有八九是版本问题。TensorFlow的API改过太多次了,网上很多老教程里的写法在最新的2.x版本里已经废弃。我现在用的方式是创建项目时先固定版本,比如tensorflow==2.10.0,然后所有代码都在这个版本下调试,避免线上教程和本地环境“打架”。
另一个很常见的坑是CPU版本的TensorFlow训练太慢。新手没必要一开始就去配GPU环境,先拿小数据集跑通流程,感受一下深度学习的完整流程,之后再逐步迁移到显卡上。当然如果你手头有支持CUDA的显卡,那就直接按官方文档装好GPU版,训练速度能快几十倍。
6. 从ANN开始的学习路径建议
ANN内容到这里基本讲完了。你现在应该能够独立完成加载数据、构建一个简单的全连接网络、训练评估模型的完整流程,也了解了归一化、损失函数、优化器、过拟合这些核心概念。这套知识框架能支撑你后续学习CNN(卷积神经网络)和RNN(循环神经网络),因为底层逻辑都是相通的。
我个人的建议是,不要在ANN上恋战太久。全连接网络虽然结构简单,但处理图像时参数数量太大且无法利用空间结构,这是它天然的短板。学完这篇,下一步直接奔着CNN去。CNN通过卷积核共享权重,既能大幅减少参数,又能很好地提取图像的局部特征,是真正让你感受到深度学习“威力”的模型。
再往后可以按这个路径走:CNN处理图像、RNN/LSTM处理序列数据、自编码器做特征压缩、注意力机制和Transformer模型处理文本和长序列。每一步都像搭积木一样递进,但地基就是今天这个简单的ANN。
最后说点实在的。我见过很多初学者在理论阶段徘徊太久,不断刷课、抄公式,但就是不动手敲代码。深度学习这门学问,眼睛会了不算会,手会了才是真会。试着把上面这个手写数字识别的模型改一改,比如把两层改成三层、把神经元改成256、把激活函数换成tanh,观察每一处的效果差异。改坏了大不了重新跑一遍,反正训练也就几分钟的事,但脑子里留下的“手感”是任何教程都替代不了的。