简介:针对深度学习初学者,一份详解基于Python与TensorFlow搭建卷积神经网络(CNN)实现猫狗识别的PDF文档,系统梳理了从数据准备到模型预测的完整项目流程。单个PDF文件,大小仅78KB,内容紧凑,不占用存储空间,适合碎片时间学习;已有4282人学习/浏览,热度在同类入门资料中较为突出。文档以Kaggle猫狗数据集为例,深入讲解get_files()函数读取图片并按文件名标注猫狗标签、将12500张猫图和12500张狗图存入列表并打乱顺序,再通过图像裁剪填充与标准化处理,以及基于tf.train.slice_input_producer和tf.train.batch构建批次输入队列的方法;随后展开CNN设计,涵盖卷积层、池化层、全连接层、Dropout防过拟合、交叉熵损失与Adam优化器,并介绍Saver模型保存及验证集评估,帮助读者掌握训练和调优的完整流程。文中附有核心代码片段和实现思路,能帮助读者快速复现项目,理解深度学习的工程细节,适合作为进一步学习迁移学习或更复杂网络结构的起点。
1. 卷积神经网络落地猫狗识别:这份资源到底能帮你省掉多少事
如果你正在找一份能直接复现的猫狗识别项目,而不是那种只讲概念、代码缺胳膊少腿的教程,这份基于 TensorFlow 的卷积神经网络实现应该能让你少走不少弯路。它把 Kaggle 上经典的 12500 张猫图、12500 张狗图数据集,从数据读取、批次生成、网络搭建到模型训练完整串了起来,核心代码分成input_data.py、model.py和训练脚本三块,逻辑相当清晰。适合刚入门 CNN、想搞懂 TensorFlow 数据队列和模型保存机制的开发者,也适合准备做图像分类项目但不想从零造轮子的从业者。跟完整个流程,你对图片数据怎么进模型、卷积池化全连接怎么衔接、训练好的模型如何存盘,会有一个非常具体的体感。
2. 数据处理层:get_files 与 get_batch 的读图流程
2.1 get_files 按文件名打标签的逻辑
这份资源的数据读取从get_files(file_dir)开始。它做的事情很直接:遍历目录下所有文件,根据文件名中是否包含cat或dog来区分标签,猫标为0,狗标为1,然后把图片路径和标签各自放进数组,最后用np.hstack合并、转置、打乱顺序。
def get_files(file_dir): cats = [] label_cats = [] dogs = [] label_dogs = [] for file in os.listdir(file_dir): name = file.split(sep='.') if 'cat' in name[0]: cats.append(file_dir + file) label_cats.append(0) else: if 'dog' in name[0]: dogs.append(file_dir + file) label_dogs.append(1) image_list = np.hstack((cats, dogs)) label_list = np.hstack((label_cats, label_dogs)) temp = np.array([image_list, label_list]) temp = temp.transpose() np.random.shuffle(temp) image_list = list(temp[:, 0]) label_list = list(temp[:, 1]) label_list = [int(i) for i in label_list] return image_list, label_list这段代码的关键不在遍历本身,而在打乱顺序这一步。如果不做np.random.shuffle(temp),训练时每个 batch 里全是同一类图片,梯度更新会来回震荡,我实际跑的时候发现准确率会卡在 50% 附近上不去。把 image 和 label 绑在同一个临时数组里再打乱,能保证路径和标签的对应关系不散。另一个细节是name[0]取的是文件名按.分割后的第一个字段,像cat.1.jpg这类命名方式正好命中,但如果你的图片命名是cat_1.jpg,split(sep='.')的结果就完全不同,需要把分隔符改成'_'才能正确分类。
2.2 get_batch 的队列化图片处理
get_batch()是这份资源里含金量比较高的一段,它把图片路径数组转成了 TensorFlow 能消费的队列数据。先tf.cast把 image 转成tf.string、label 转成tf.int32,然后用tf.train.slice_input_producer建队列,每读取一个样本就解码 JPEG、裁剪或填充到统一尺寸、做标准化,最后通过tf.train.batch按批次输出。
def get_batch(image, label, image_W, image_H, batch_size, capacity): image = tf.cast(image, tf.string) label = tf.cast(label, tf.int32) input_queue = tf.train.slice_input_producer([image, label]) label = input_queue[1] image_contents = tf.read_file(input_queue[0]) image = tf.image.decode_jpeg(image_contents, channels=3) image = tf.image.resize_image_with_crop_or_pad(image, image_W, image_H) image = tf.image.per_image_standardization(image) image_batch, label_batch = tf.train.batch([image, label], batch_size=batch_size, num_threads=64, capacity=capacity) label_batch = tf.reshape(label_batch, [batch_size]) image_batch = tf.cast(image_batch, tf.float32) return image_batch, label_batch这里值得注意的有三个参数。image_W和image_H是最终输入网络的图片尺寸,资源里设置的是 208×208,这个大小对 GPU 显存不算友好,但 CPU 训练时倒是比较适中。capacity是队列容量,资源里取 256,我一般建议至少是batch_size的 4 到 8 倍,太小会让队列频繁空转,太大则占内存。num_threads=64看起来吓人,实际上如果你的机器核心数没那么多,这个值并不会让训练更快,反而可能因为线程切换开销导致 CPU 飙高,我自己的经验是设成 4 到 8 就够用了。
tf.image.per_image_standardization这一步经常被忽略,它把每个通道的像素减去均值再除以方差,让输入特征分布在零附近,这对卷积网络的收敛速度帮助很大。你可以在训练时对比去掉这行代码的效果,loss 下降会明显变慢,而且初期波动更剧烈。
3. 网络结构:从 conv1 到 softmax 的模型长什么样
3.1 conv1 到 pooling1_lrn:第一层卷积核的选择
model.py里的网络结构是典型的小型 CNN,没有用预训练模型,也没有复杂残差结构,就是靠卷积、池化、LRN、全连接一层层堆出来的。第一层卷积conv1的卷积核是[3, 3, 3, 16],意思是 3×3 的窗口大小、输入通道数为 3(RGB)、输出 16 个 feature map。
with tf.variable_scope('conv1') as scope: weights = tf.get_variable('weights', shape=[3, 3, 3, 16], dtype=tf.float32, initializer=tf.truncated_normal_initializer(stddev=0.1, dtype=tf.float32)) biases = tf.get_variable('biases', shape=[16], dtype=tf.float32, initializer=tf.constant_initializer(0.1)) conv = tf.nn.conv2d(images, weights, strides=[1, 1, 1, 1], padding='SAME') pre_activation = tf.nn.bias_add(conv, biases) conv1 = tf.nn.relu(pre_activation, name=scope.name)为什么第一层就用 16 个 feature map?对 208×208 的输入来说,3×3 卷积滑一遍后尺寸不变(SAME padding),16 个通道相当于提取了 16 种不同的低级特征,比如边缘、颜色块、纹理方向。这个数量对猫狗分类这种相对粗粒度的任务足够,往上加到 64 或 128 也不是不行,但参数量会翻好几倍,在 25000 张图的规模下收益不大。stddev=0.1是权重初始化的标准差,如果设成 1.0,深层网络很容易梯度爆炸,我见过直接 loss 变成 NaN 的情况。
池化层用的是max_pool,ksize=[1, 3, 3, 1]、strides=[1, 2, 2, 1],也就是 3×3 的池化窗口步长为 2,能把特征图尺寸从 208×208 压到 104×104。资源里还在池化后加了一层tf.nn.lrn,这个 LRN(局部响应归一化)在 AlexNet 时代比较流行,作用是让响应比较大的神经元相对更强、响应小的被抑制。现在的 ResNet、EfficientNet 里已经不太用 LRN 了,但这套代码里保留它并不会坏事,只是训练速度会稍微慢一点。
3.2 conv2 到 softmax:从特征图到分类输出
第二层卷积conv2的输入是上一层的 16 通道特征图,卷积核尺寸为[3, 3, 16, 16],输出仍然是 16 个 feature map。这里输入输出通道数一样,意味着这一层主要做的是空间特征组合,而不是通道扩展。紧接着又是一个max_pool,但这次strides=[1, 1, 1, 1],池化后尺寸只从 104×104 变成 103×103 左右,压缩幅度比第一层温和得多。
with tf.variable_scope('local3') as scope: reshape = tf.reshape(pool2, shape=[batch_size, -1]) dim = reshape.get_shape()[1].value weights = tf.get_variable('weights', shape=[dim, 128], dtype=tf.float32, initializer=tf.truncated_normal_initializer(stddev=0.005, dtype=tf.float32)) biases = tf.get_variable('biases', shape=[128], dtype=tf.float32, initializer=tf.constant_initializer(0.1)) local3 = tf.nn.relu(tf.matmul(reshape, weights) + biases, name=scope.name)从pool2到local3之间有个关键操作:tf.reshape(pool2, shape=[batch_size, -1]),-1表示自动推断维度,把三维特征图展平成一维向量,然后接全连接层。这里的dim是展平后的特征数量,资源里用reshape.get_shape()[1].value动态获取,避免了手写死数字可能造成的维度不匹配。展平、乘权重、加偏置、过 ReLU,然后接第二个全连接层local4(128→128),最后是softmax_linear,输出维度 2,对应猫和狗两个类别的 logits。
整个网络的参数规模大致如下:
| 层名 | 参数 shape | 参数量 | 输出尺寸 |
|---|---|---|---|
| conv1 | [3, 3, 3, 16] | 448 | 208×208×16 |
| pooling1_lrn | — | — | 104×104×16 |
| conv2 | [3, 3, 16, 16] | 2320 | 104×104×16 |
| pooling2_lrn | — | — | 103×103×16 |
| local3 | [dim, 128] | about 21 万 | 128 |
| local4 | [128, 128] | 16512 | 128 |
| softmax_linear | [128, 2] | 258 | 2 |
我实际跑下来的感觉是,这个网络在 25000 张图上训练,准确率能到 85% 左右,但再往上走就比较吃力了,原因是网络容量有限,也没有做数据增强。如果你用这份代码训练到后期发现 loss 不再下降,可以考虑在conv2后面再加一层[3, 3, 16, 32]的卷积,然后把local3的节点数从 128 提到 256,效果会有明显改观。
4. 训练主控:run_training 的参数设置与模型保存
4.1 训练主循环:step、loss 与 accuracy 的节奏
训练脚本里把前面两个模块整合成了run_training()函数,核心参数如下:IMG_W=208、IMG_H=208、BATCH_SIZE=32、CAPACITY=256、MAX_STEP=15000、learning_rate=0.0001。这些参数的选择有实际考量,批量大小 32 在 CPU 上跑不会让内存爆炸,学习率 0.0001 对 Adam 优化器来说偏保守,但换来了训练稳定性。
def run_training(): train_dir = '/path/to/your/train_images/' logs_train_dir = '/path/to/your/save/model/' train, train_label = input_data.get_files(train_dir) train_batch, train_label_batch = input_data.get_batch(train, train_label, IMG_W, IMG_H, BATCH_SIZE, CAPACITY) train_logits = model.inference(train_batch, BATCH_SIZE, N_CLASSES) train_loss = model.losses(train_logits, train_label_batch) train_op = model.trainning(train_loss, learning_rate) train__acc = model.evaluation(train_logits, train_label_batch) summary_op = tf.summary.merge_all() sess = tf.Session() train_writer = tf.summary.FileWriter(logs_train_dir, sess.graph) saver = tf.train.Saver() sess.run(tf.global_variables_initializer()) coord = tf.train.Coordinator() threads = tf.train.start_queue_runners(sess=sess, coord=coord)这里有几个容易忽略的点。tf.train.start_queue_runners必须调用,否则slice_input_producer创建的队列不会自动填充数据,sess.run会一直卡住不返回。资源里用Coordinator来管理线程生命周期,这是一种相对规范的写法,训练结束时先coord.request_stop()再coord.join(threads),能避免线程残留导致的程序崩溃。
每隔 50 步打印一次 loss 和 accuracy,这个频率在调试时比较舒服,不会刷屏又能及时发现问题。我一般还会在每 200 步打印一次当前 batch 里的真实标签分布,确认队列读到的不是全猫或全狗。
4.2 模型保存与 checkpoint 的恢复
资源在每 2000 步保存一次模型,用的是saver.save(sess, checkpoint_path, global_step=step),保存路径是logs_train_dir下的model.ckpt,实际生成的文件会带上-2000、-4000这样的后缀。
| 文件名 | 作用 |
|---|---|
model.ckpt-2000.index | 变量名与张量形状的索引 |
model.ckpt-2000.data-00000-of-00001 | 变量具体数值 |
model.ckpt-2000.meta | 计算图的元数据,恢复结构时用 |
checkpoint | 最近一次保存的模型路径记录 |
如果你是训练到一半想接着训,加载时需要用saver.restore(sess, tf.train.latest_checkpoint(logs_train_dir)),它会自动找到checkpoint文件里记录的最新模型编号。有个细节:global_step参数会让保存的文件名带上步数,但如果tf.train.Saver()创建时没有指定max_to_keep,默认只保留最后 5 个 checkpoint,磁盘紧张的项目里要注意这点,可以在Saver()里加max_to_keep=20或者训完手动清理旧文件。
这里有一个我自己踩过的坑:如果训练中断后恢复,learning_rate还是按初始值继续,并没有做学习率衰减。对 15000 步的训练来说,后面几千步的学习率其实可以降一半甚至更少,能有效减少 loss 的震荡。你可以把learning_rate定义成tf.train.exponential_decay的形式,让它随global_step自动减小,但资源里的原版代码没有这一步,这算是第一个可以优化的点。
5. 避坑:这 5 个 TensorFlow 老问题最容易被新人碰到
5.1 现象:训练卡住不动,CPU 占用率很低
原因:tf.train.slice_input_producer创建的队列需要start_queue_runners才能启动填充线程,如果只sess.run(tf.global_variables_initializer())就进入主循环,队列一直为空,sess.run([train_op, train_loss, train__acc])会永久阻塞。
解决:训练前必须执行threads = tf.train.start_queue_runners(sess=sess, coord=coord),并在结束时用coord.request_stop()和coord.join(threads)收尾。资源代码里这个顺序是完整的,但如果你自己重构代码时漏掉这一行,就会复现这个现象。
5.2 现象:训练时 loss 在某个值附近反复横跳,准确率上不去
原因:get_files打乱顺序后,每个 batch 内仍然可能只是某一类图片,尤其是数据集没有 shuffle 充分时。另一个常见原因是learning_rate=0.0001对 Adam 来说后期偏大,导致权重在小范围内震荡。
解决:先把get_files里的np.random.shuffle(temp)确认执行了,再检查tf.train.batch是否设置了shuffle=True。资源里用的是tf.train.batch,它是按顺序取队列里的样本,如果前面打乱得不够充分,建议换成tf.train.shuffle_batch并设置min_after_dequeue=capacity//2,这样每个 batch 都有随机性。
5.3 现象:图片解码报错或者尺寸对不上
原因:数据集中可能混入了非 JPEG 格式的图片,或者图片本身就是灰度图,decode_jpeg(..., channels=3)在读灰度图时不会报错,但读 PNG 或 BMP 会直接抛异常。
解决:把tf.image.decode_jpeg改成tf.image.decode_image,它能自适应多种格式,但要注意decode_image返回的通道数不固定,需要手动tf.image.resize_image_with_crop_or_pad后再用tf.image.per_image_standardization。如果还是报错,可以写一个脚本先过滤掉无法解码的图片文件,常见做法是用 PIL 批量打开再转存 JPEG。
5.4 现象:标准化后图片显示全黑或全白
原因:per_image_standardization会把像素值变成零均值单位方差,范围一般在 [-2, 2] 之间,直接用plt.imshow会把它当成 0-1 或 0-255 范围显示,结果就是一片黑或一片白。
解决:可视化验证时,把标准化后的数据逆操作回去,常见做法是img = img * std + mean,或者直接在看图时用np.clip(img * 128 + 128, 0, 255)。这只影响调试,不影响模型训练。
5.5 现象:训练后期准确率到 85% 左右就停了,再训也上不去
原因:这个网络结构本身容量有限,二分类任务在 25000 张图上的特征区分度不够强,而且没有数据增强、没有 dropout、没有学习率衰减。
解决:按顺序做三件事。第一,在local4和softmax_linear之间插入tf.nn.dropout(local4, keep_prob=0.5),注意keep_prob训练时设 0.5,测试时设 1.0。第二,把训练集做数据增强,比如随机翻转、微调亮度、随机裁剪,我一般会用tf.image.random_flip_left_right和tf.image.random_brightness,代码量不大但能明显改善泛化。第三,把learning_rate改成指数衰减,初始值 0.0001,衰减率 0.9,衰减步长 2000,这几步做完准确率能往上走 5 到 8 个百分点。
6. 模型持久化与自写负载测试:给模型加一个可复现的验证程序
训练脚本末尾用saver.save(sess, checkpoint_path, global_step=step)把整个会话里的变量存盘,这一步是后续做预测的前提。你会发现保存目录下多出了好几种文件,其中.index记录变量名与形状、.data存具体数值、.meta存计算图结构。恢复模型的标准做法是先加载.meta重建图结构,再restore恢复变量值,但如果你是在同一个 Python 进程里接着训练,直接用saver.restore(sess, tf.train.latest_checkpoint(logs_train_dir))更省事。
def predict_one_image(sess, image_path, model_path): image_data = tf.gfile.FastGFile(image_path, 'rb').read() image = tf.image.decode_jpeg(image_data, channels=3) image = tf.image.resize_image_with_crop_or_pad(image, IMG_W, IMG_H) image = tf.image.per_image_standardization(image) image = tf.expand_dims(image, 0) image = tf.cast(image, tf.float32) saver = tf.train.import_meta_graph(model_path + '.meta') saver.restore(sess, model_path) logits = sess.run('softmax_linear/softmax_linear:0', feed_dict={'Placeholder:0': image.eval()}) return logits上面这段代码是我自己项目里常用的预测模板,softmax_linear/softmax_linear:0是模型输出 tensor 的名字,Placeholder:0是输入占位符,这两个名字取决于model.py里实际定义的variable_scope和name。如果你仿照这份资源的inference()函数搭建新网络,要先去 TensorBoard 的 graph 里确认节点名称,否则sess.run会报找不到 tensor 的错误。
预测时有一个真实的工程细节:加载模型后,图片的预处理必须与训练保持一致,包括裁剪尺寸、标准化方法、是否翻转等,任何一项不一致都会导致输出的概率分布异常。我遇到过有人在训练时用resize_image_with_crop_or_pad,预测时却用tf.image.resize_images直接拉伸,结果同一张图的预测分数完全对不上,这不是模型问题,而是数据分布变了。
做完这些之后,我每次拿到一套新训练好的模型,都会强制自己走一遍完整的验证流程:先用训练集里的图片跑 top-1 准确率确认模型没坏,再拿一批网上下载的、分辨率各不相同、角度倾斜、光照偏暗的真实猫狗图片做一轮压力测试,看模型的泛化能力到底在什么水平。这套猫狗识别资源我拆过不少相似的,但这份代码在数据队列和模型保存上的处理是最接近生产流程的,值得在本地完整跑一遍。希望帮到你。
本文还有配套的精品资源,点击获取