手写数字识别项目入门:先读懂 train.csv、test.csv 和标签结构
手写数字识别项目入门:先读懂 train.csv、test.csv 和标签结构
站内搜索
直接问 AI

手写数字识别项目入门:先读懂 train.csv、test.csv 和标签结构

这组手写数字项目来自一个非常典型的入门场景:我们拿到一份已经展开成表格的图像数据,目标是根据 28 x 28 像素的灰度值预测数字 0 到 9。和很多只讲模型公式的文章不同,这个项目更适合从“文件结构”和“数据长什么样”开始读,因为后面的 C 程序、浏览器实验台和提交文件都建立在同一套输入格式上。

如果你已经会一点 C 或 Python,这类项目是很好的过渡练习。它既不像纯算法题那样只有抽象状态,也不像完整深度学习项目那样一开始就需要复杂框架。先把数据读懂,后面的训练、预测和调试会顺很多。

一、这个项目里有哪些文件

  • train.csv:训练集,共 42000 条样本,每条样本包含 1 个标签和 784 个像素值
  • test.csv:测试集,共 28000 条样本,只包含 784 个像素值,没有标签
  • sample_submission.csv:官方给出的提交格式示例
  • submission.csv:当前项目运行后生成的预测结果
  • digit_softmax_classifier.c:本项目的 C 语言实现

这类结构很适合做监督学习入门,因为训练集和测试集分工很清楚:训练集负责学习参数,测试集负责生成最终预测结果。

二、train.csv 的每一行到底是什么

train.csv 的第一列是标签,也就是这张图片真实对应的数字。后面 784 列是像素值,范围通常在 0 到 255 之间:

label,pixel0,pixel1,pixel2,...,pixel783
5,0,0,0,0,...,0
0,0,0,12,178,...,0
4,0,0,0,0,...,0

这里最重要的理解是:原始图像已经被“拉平”成一个长度为 784 的向量。也就是说,程序读到的不是图片文件,而是一行一行的数字表格。

因为 28 x 28 = 784,所以你可以把它理解成:

第 1 行像素  +  第 2 行像素  +  ...  +  第 28 行像素
= 一条长度为 784 的特征向量

这就是为什么传统的线性分类器也能直接拿它做输入:对模型来说,它只是一组 784 维数值特征。

三、test.csv 和训练集的区别

test.csv 只有像素,没有标签。这意味着程序不能再拿它继续训练,而是要基于已经学到的参数直接做预测:

  • 训练时:输入特征 + 正确答案
  • 预测时:只有输入特征,没有正确答案

这一步在初学者项目里很关键,因为它会逼着你把“训练逻辑”和“推理逻辑”分开写。项目里最后导出的 submission.csv,本质上就是把测试集逐条送进模型之后得到的标签结果。

四、C 程序是怎么把这些数据读进来的

这个项目的读取方式比较直接:先按逗号切开每一行,再把第一个字段当成标签,把后面的 784 个字段当成像素。

y_train[sample_count] = atoi(tokens[0]);
for (int j = 0; j < FEATURES; j++) {
    X_train[sample_count][j] = atof(tokens[j + 1]) / 255.0;
}

这里有两个重要细节:

  • 标签单独保存:便于后续计算损失和判断预测是否正确
  • 像素除以 255:把原始灰度值压到 0 到 1 之间,训练会更稳定

如果你直接把 0 到 255 的原始像素塞给一个梯度下降模型,参数更新会更容易受尺度影响。对这类表格化图像项目来说,做一次简单归一化几乎是默认操作。

五、为什么这种“表格图像”特别适合入门

它有三个优点:

  • 数据结构简单:不需要先学图像文件解码
  • 标签明确:10 个数字类别,适合多分类练习
  • 调试直接:任何一行都能拿出来还原成 28 x 28 网格查看

也正因为这样,这个项目很适合把“机器学习流程”真正串起来:读入数据、归一化、训练参数、输出预测,再把预测写回 CSV 文件。

六、开始训练前最值得先检查什么

如果你准备自己写一个版本,建议先确认下面几件事:

  • 有没有正确跳过表头
  • 训练集行数是不是接近 42000,测试集是不是接近 28000
  • 每行是不是刚好有 785 或 784 个字段
  • 像素值是否已经缩放到 0 到 1
  • 标签是不是仍然保持在 0 到 9 之间

这些检查比换模型更基础。很多训练失败并不是算法错误,而是 CSV 没读对、字段偏移、或者归一化漏掉了。

七、用几行统计先确认数据没有读歪

在训练模型之前,最好先做一次小型数据体检。它不需要复杂可视化,只要确认标签分布、像素范围和空白像素比例是否合理。下面这类检查能很快发现字段错位、缺行、表头没跳过或像素没有归一化的问题。

import csv
from collections import Counter

labels = Counter()
pixel_min, pixel_max = 255, 0
nonzero_pixels = 0
total_pixels = 0

with open("train.csv", newline="") as f:
    reader = csv.reader(f)
    header = next(reader)
    for row in reader:
        labels[int(row[0])] += 1
        pixels = [int(v) for v in row[1:]]
        pixel_min = min(pixel_min, min(pixels))
        pixel_max = max(pixel_max, max(pixels))
        nonzero_pixels += sum(v > 0 for v in pixels)
        total_pixels += len(pixels)

print(labels)
print(pixel_min, pixel_max, nonzero_pixels / total_pixels)
检查项 正常信号 异常时可能说明什么
标签分布 0 到 9 都有足够样本 标签列读错、文件被截断或类别严重不平衡
像素范围 原始 CSV 在 0 到 255 之间 字段偏移、非数字内容或归一化重复执行
非零像素比例 远低于 1,但不能接近 0 图片全黑、读取空列或分隔符解析错误
每行字段数 训练集 785,测试集 784 换行、缺列、额外逗号或表头处理错误

八、如何把一行数据还原成 28 x 28

只看 CSV 行很难发现图像是否读反、读偏或顺序错了。最直接的办法是把任意一行的 784 个像素重新按 28 列切开,打印成字符图或保存成小图片。哪怕不用绘图库,也可以用简单字符判断数字轮廓是否合理。

pixels = [int(v) for v in row[1:]]
for r in range(28):
    line = pixels[r * 28:(r + 1) * 28]
    print("".join("#" if v > 80 else "." for v in line))

如果字符图完全看不出数字,先不要怀疑模型。更应该回到读取流程,检查表头、分隔符、字段数量和像素顺序。对机器学习项目来说,能把输入样本还原成人能检查的形式,是非常重要的调试能力。

两个文件的列错位一格,而且不会报错

第三节说了 test.csv 没有 label 列。这句话很容易读过去,但它带来的后果值得单独说:两个文件的第 0 列含义不同。

train.csv:  label, pixel0, pixel1, ..., pixel783    共 785 列
test.csv:          pixel0, pixel1, ..., pixel783    共 784 列

如果读取代码在两处共用同一套列索引,训练时 row[1] 是 pixel0,推理时 row[1] 却是 pixel1——整张图整体平移了一个像素

这个 bug 的恶劣之处在于它完全不报错:列数够、类型对、程序跑得很顺,训练准确率也正常(训练路径是对的)。只有提交结果的分数莫名偏低,而你会去怀疑模型、怀疑超参数,很难想到是读数据错了一格。

防住它的办法是读文件时按表头定位,不按硬编码下标;如果坚持用下标,就在两条路径上各留一个断言:

/* 训练集:第一个字段必须是 0-9 的标签 */
assert(label >= 0 && label <= 9);
/* 两边都必须凑满 784 个像素 */
assert(pixel_count == 784);

更直观的验证是把读进来的第一行还原成 28×28 打印出来(第八节给了方法)。错位一格的图会整体斜一点,肉眼一眼能看出来——而这个检查只需要跑一次。

像素值不归一化,等于把学习率放大 255 倍

CSV 里的像素是 0–255 的整数。直接把它们喂进模型能跑,但训练会很不稳定,原因值得算一遍。

线性层里某个权重的梯度正比于它对应的输入值。输入是 0–255 时,梯度的量级就比输入是 0–1 时大了两个数量级。而学习率是所有参数共用的,于是同一个学习率在归一化前后表达的实际步长差了 255 倍。

症状是:学习率稍微大一点点就发散(loss 变成 nan 或剧烈震荡),调到能收敛的值又慢得离谱。很多人会以为是模型不行或者数据太难,实际上只是尺度没对齐。

x[i] = raw[i] / 255.0;      /* 一行的事 */

还有一个相关但更隐蔽的问题:训练和推理必须用同一套归一化。如果训练时除了 255、推理时忘了除,模型收到的输入比训练时大 255 倍,输出会完全没有意义——同样不报错。把归一化写成一个函数、两条路径都调它,比在两处各写一遍可靠得多。

顺带一提,MNIST 这类数据集因为所有像素都在同一个尺度上,只需要除以 255 就够了。特征量纲各不相同的表格数据要用标准化(减均值除标准差),而且均值和标准差只能从训练集算——用全量数据算会造成数据泄漏。

九、接下来该读哪篇

如果你已经看懂了这份数据长什么样,下一步建议直接读 用 C 实现手写数字 Softmax 分类器。那篇会把这 784 维输入如何经过权重矩阵、softmax 和梯度更新,最终变成 submission.csv 讲清楚。

项目文件和压缩数据已经放到 下载页的手写数字资源区;如果你想直接试网页上的轻量演示,可以继续打开 算法实验台 里的手写数字标签页。

发表回复

向下探索