简介:这份资源面向机器学习零基础或刚入门的学习者,围绕四个经典项目展开:手写数字识别、声呐目标分类、垃圾短信识别与鸢尾花分类,帮助读者在动手实践中理解分类任务的基本流程与常见算法。压缩包共13个文件,约630KB,包含4个csv数据集、3个ipynb交互式笔记本、3个py源码脚本、2个txt说明及1个md文档,覆盖数据读取、模型训练与结果验证等环节,结构清晰便于按项目逐个学习。目前已有142人学习下载,适合作为课程实验或自学练手材料。读者可借助源码与笔记本直接运行调试,掌握特征处理、模型评估与调参思路,并对照四个案例举一反三,快速建立机器学习项目的完整认知。
1. 四个练手项目里,哪个最容易被小白低估
很多人第一次接触机器学习,都是从手写数字识别开始的。MNIST 这个数据集几乎成了行业里的“Hello World”,网上教程一抓一大把,跑通一个 98% 准确率的模型可能只需要二十行代码。但问题恰恰出在这里:跑通不等于理解,准确率高不等于你真的会做机器学习。我见过太多人拿着现成的 notebook 跑完一遍,换一个数据集就完全不知道从哪下手。
标题里提到的四个项目——手写数字识别、声呐分类、垃圾短信识别、鸢尾花分类——其实是一条非常完整的入门路径。它们分别对应了图像、信号、文本、表格四种数据形态,覆盖了分类任务里最典型的几个场景。鸢尾花帮你建立最基本的特征与标签概念,手写数字让你接触图像数据的预处理和卷积网络,垃圾短信识别带你进入文本向量化和朴素贝叶斯的领域,声呐分类则是一个被严重低估的练手项目——它的特征维度高、样本量小,非常考验你对过拟合的理解。
这篇文章面向的是想用这四个项目真正入门机器学习的人。我不会只给你一段能跑的代码就结束,而是把每个项目的选型理由、关键参数、常见翻车点都讲清楚。你跟着做一遍,应该能建立起一套可复用的分类任务流程,而不是只会跑某个特定数据集。
2. 四个项目的数据形态与模型选型逻辑
2.1 为什么鸢尾花和手写数字适合作为起点
鸢尾花数据集只有 150 个样本,4 个特征,3 个类别。它的价值不在于难度,而在于让你把“特征矩阵 X”和“标签向量 y”这两个概念刻进肌肉记忆里。很多人跳过这一步直接上深度学习,结果连训练集和测试集为什么要分开都说不清楚。
手写数字识别用的是 28x28 的灰度图,每个样本 784 个像素特征。它的核心教学点是:图像数据怎么归一化、为什么要做归一化、卷积核到底在提取什么。我一般会建议先用全连接网络跑一个基线,再换成卷积网络对比效果,这样你能直观感受到“局部感受野”带来的差异。
2.2 垃圾短信识别和声呐分类的独特价值
垃圾短信识别是典型的文本二分类问题。它的流程比图像多了一步:文本要转成数值向量。常见做法是词袋模型加 TF-IDF 加权,然后接朴素贝叶斯或线性 SVM。这个项目能让你理解“特征工程”在文本任务里的分量——同样的分类器,特征做得好不好,效果可能差十几个百分点。
声呐分类用的是 UCI 的 Sonar 数据集,60 个特征,208 个样本,二分类。它的特点是特征维度接近样本量,模型非常容易过拟合。这个项目是检验你正则化功底的试金石。如果你在鸢尾花上随便调参都能到 95%,在声呐数据上可能会发现训练集准确率 100%、测试集只有 70%。这种落差感,恰恰是让你真正理解泛化能力的时刻。
2.3 四个项目的统一实验框架
与其每个项目写一套代码,不如搭一个统一的实验框架。下面这个结构我用了很久,四个项目都能套进去:
# ml_pipeline.py # 统一的分类任务实验框架,四个项目共用 import numpy as np from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.metrics import classification_report, confusion_matrix def load_and_split(data, label, test_size=0.2, random_state=42): """ 统一的数据划分函数 data: 特征矩阵,shape=(n_samples, n_features) label: 标签向量,shape=(n_samples,) test_size: 测试集比例,小样本数据集建议 0.2~0.3 random_state: 固定随机种子,保证结果可复现 """ X_train, X_test, y_train, y_test = train_test_split( data, label, test_size=test_size, random_state=random_state, stratify=label ) # stratify 保证训练集和测试集的类别比例一致,小样本时尤其重要 return X_train, X_test, y_train, y_test def normalize(X_train, X_test): """ 标准化:用训练集的均值和方差去变换测试集 这是最容易翻车的地方——千万不要用全量数据 fit """ scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) # 只在训练集上 fit X_test_scaled = scaler.transform(X_test) # 测试集只做 transform return X_train_scaled, X_test_scaled, scaler def evaluate(model, X_test, y_test, target_names=None): """ 统一的评估输出:分类报告 + 混淆矩阵 """ y_pred = model.predict(X_test) print(classification_report(y_test, y_pred, target_names=target_names)) print("混淆矩阵:") print(confusion_matrix(y_test, y_pred)) return y_pred这段代码里最关键的参数是stratify=label。在小样本数据集上,如果不做分层抽样,可能出现某个类别在测试集里一个样本都没有的情况,评估结果就完全失真了。另一个重点是标准化那一步:fit_transform只能用在训练集上,测试集必须用训练集学到的均值和方差来做transform。这个坑我见过太多人踩,包括一些已经工作了一两年的工程师。
注意:标准化不是必须的。鸢尾花和声呐数据建议做,手写数字如果像素值已经归一化到 0-1 之间可以跳过,垃圾短信的 TF-IDF 向量本身已经是归一化的。
3. 手写数字识别:从全连接到卷积的完整实现
3.1 数据加载与预处理的三个关键参数
手写数字识别我一般用两种方式加载数据:一种是直接从 sklearn 的load_digits拿 8x8 的小图,另一种是从原始 IDX 文件读 28x28 的完整版。前者适合快速验证,后者更接近真实场景。
# mnist_baseline.py # 手写数字识别基线模型 from sklearn.datasets import load_digits from sklearn.linear_model import LogisticRegression from sklearn.neural_network import MLPClassifier from ml_pipeline import load_and_split, normalize, evaluate # 加载 8x8 版本,1797 个样本 digits = load_digits() X, y = digits.data, digits.target # 划分数据集,手写数字样本量适中,测试集 20% 足够 X_train, X_test, y_train, y_test = load_and_split(X, y, test_size=0.2) # 像素值范围是 0-16,做标准化 X_train, X_test, scaler = normalize(X_train, X_test) # 基线模型:逻辑回归 lr = LogisticRegression(max_iter=5000, C=1.0, solver='lbfgs') lr.fit(X_train, y_train) print("=== 逻辑回归基线 ===") evaluate(lr, X_test, y_test) # 多层感知机 mlp = MLPClassifier( hidden_layer_sizes=(128, 64), # 两层隐藏层 activation='relu', alpha=0.001, # L2 正则化系数 max_iter=500, random_state=42 ) mlp.fit(X_train, y_train) print("=== MLP 模型 ===") evaluate(mlp, X_test, y_test)LogisticRegression的max_iter默认是 100,在手写数字这种多分类任务上经常不收敛,调到 5000 是常规操作。C是正则化强度的倒数,越小正则化越强。MLPClassifier的alpha参数控制 L2 惩罚,0.001 是一个比较稳的起点。hidden_layer_sizes=(128, 64)表示两层隐藏层,第一层 128 个神经元,第二层 64 个。这个结构在 8x8 数据上已经足够,再深就容易过拟合。
3.2 用卷积网络处理 28x28 完整版数据
如果你用的是完整版 MNIST,全连接网络的效果会明显下降,因为 784 维输入里大部分像素是冗余的。这时候卷积网络的优势就体现出来了。
# mnist_cnn.py # 用 PyTorch 搭建一个简单的卷积网络 import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader, TensorDataset class SimpleCNN(nn.Module): def __init__(self, num_classes=10): super(SimpleCNN, self).__init__() # 第一个卷积块:1 通道输入,8 个卷积核,3x3 self.conv1 = nn.Sequential( nn.Conv2d(1, 8, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(kernel_size=2) # 28x28 -> 14x14 ) # 第二个卷积块:8 通道输入,16 个卷积核 self.conv2 = nn.Sequential( nn.Conv2d(8, 16, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(kernel_size=2) # 14x14 -> 7x7 ) # 全连接层:16*7*7 = 784 维输入 self.fc = nn.Sequential( nn.Linear(16 * 7 * 7, 64), nn.ReLU(), nn.Dropout(0.3), # 丢弃率 0.3,防止过拟合 nn.Linear(64, num_classes) ) def forward(self, x): x = self.conv1(x) x = self.conv2(x) x = x.view(x.size(0), -1) # 展平 x = self.fc(x) return x # 训练配置 device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = SimpleCNN().to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=0.001) # 假设 train_loader 和 test_loader 已经准备好 # 训练循环 for epoch in range(10): model.train() running_loss = 0.0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() print(f"Epoch {epoch+1}, Loss: {running_loss/len(train_loader):.4f}")卷积核的数量我选了 8 和 16,这是比较保守的配置。如果你用 32 和 64,训练时间会翻倍,但在 MNIST 上准确率提升不到 0.5 个百分点。Dropout(0.3)放在全连接层之前,是防止过拟合的常规手段。学习率用 Adam 的默认值 0.001 就行,MNIST 这个任务不太需要精细调学习率。
3.3 训练过程中要看哪些指标
不要只盯着准确率。我一般会同时看三个东西:训练 loss 的下降曲线、验证集准确率的变化、以及混淆矩阵里哪些数字容易被搞混。MNIST 上最常见的混淆是 4 和 9、3 和 8、5 和 6。如果你发现某两个数字的混淆特别严重,可以针对性地做数据增强,比如对这两类样本做轻微的旋转和平移。
另一个容易被忽略的点是:测试集准确率 98% 和 99% 之间的差距,可能比你想象的大。从 98% 到 99%,意味着错误率降低了一半。在工业场景里,这个提升可能需要付出几倍的努力。所以入门阶段不要追求极致准确率,先把流程跑通、把每个参数的含义搞清楚。
4. 声呐分类与垃圾短信识别:小样本和高维特征的挑战
4.1 声呐数据的过拟合陷阱与正则化手段
声呐数据集有 60 个特征,208 个样本。如果你直接上一个全连接网络,训练集准确率可以轻松到 100%,但测试集可能只有 70% 出头。这就是典型的过拟合。
# sonar_classify.py # 声呐分类:对比不同正则化策略 import pandas as pd import numpy as np from sklearn.model_selection import cross_val_score, StratifiedKFold from sklearn.linear_model import LogisticRegression from sklearn.svm import SVC from sklearn.ensemble import RandomForestClassifier from ml_pipeline import load_and_split, normalize, evaluate # 加载声呐数据(假设已下载 sonar.csv) # 数据格式:60 个特征列 + 1 个标签列(R 或 M) df = pd.read_csv('sonar.csv', header=None) X = df.iloc[:, :-1].values y = df.iloc[:, -1].values # 标签编码:R -> 0, M -> 1 from sklearn.preprocessing import LabelEncoder le = LabelEncoder() y = le.fit_transform(y) X_train, X_test, y_train, y_test = load_and_split(X, y, test_size=0.25) X_train, X_test, scaler = normalize(X_train, X_test) # 策略一:L2 正则化的逻辑回归 lr_l2 = LogisticRegression(penalty='l2', C=0.1, max_iter=5000) lr_l2.fit(X_train, y_train) print("=== L2 逻辑回归 ===") evaluate(lr_l2, X_test, y_test, target_names=['Rock', 'Mine']) # 策略二:L1 正则化(自带特征选择效果) lr_l1 = LogisticRegression(penalty='l1', C=0.5, solver='saga', max_iter=5000) lr_l1.fit(X_train, y_train) print("=== L1 逻辑回归 ===") evaluate(lr_l1, X_test, y_test, target_names=['Rock', 'Mine']) # 策略三:SVM + 交叉验证选参 svm = SVC(kernel='rbf', C=1.0, gamma='scale', probability=True) svm.fit(X_train, y_train) print("=== SVM ===") evaluate(svm, X_test, y_test, target_names=['Rock', 'Mine']) # 用交叉验证评估稳定性 cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) scores = cross_val_score(svm, X_train, y_train, cv=cv, scoring='accuracy') print(f"SVM 5折交叉验证准确率:{scores.mean():.4f} (+/- {scores.std():.4f})")C=0.1的 L2 正则化是我在声呐数据上试出来比较稳的参数。C 越小,正则化越强,模型越保守。L1 正则化会让部分特征权重变成 0,相当于自动做了特征选择,但需要配合saga求解器。SVM 的gamma='scale'是 sklearn 的默认值,相当于 1/(n_features * X.var()),在特征维度高的时候比'auto'更稳。
交叉验证那一步很重要。单次划分的测试集准确率波动可能很大,5 折交叉验证的均值和标准差能告诉你模型到底稳不稳定。如果标准差超过 0.05,说明模型对数据划分太敏感,需要换更简单的模型或者加更强的正则化。
4.2 垃圾短信识别的文本向量化流程
垃圾短信识别的核心不在分类器,而在文本怎么转成向量。我一般用 TF-IDF,因为它比简单的词频统计更能反映词语的重要性。
# spam_classify.py # 垃圾短信识别:TF-IDF + 朴素贝叶斯 import pandas as pd from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.naive_bayes import MultinomialNB from sklearn.linear_model import LogisticRegression from sklearn.pipeline import Pipeline from sklearn.model_selection import train_test_split from sklearn.metrics import classification_report # 加载数据(假设是两列:label, message) df = pd.read_csv('spam.csv', encoding='latin-1') df = df[['v1', 'v2']].rename(columns={'v1': 'label', 'v2': 'message'}) df['label_num'] = df['label'].map({'ham': 0, 'spam': 1}) X_train, X_test, y_train, y_test = train_test_split( df['message'], df['label_num'], test_size=0.2, random_state=42, stratify=df['label_num'] ) # 用 Pipeline 把向量化和分类器串起来 pipeline = Pipeline([ ('tfidf', TfidfVectorizer( max_features=5000, # 最多保留 5000 个词 ngram_range=(1, 2), # 考虑单个词和相邻词对 min_df=2, # 至少出现在 2 个文档里的词才保留 max_df=0.95, # 出现在超过 95% 文档里的词丢弃 stop_words='english' # 去掉英文停用词 )), ('clf', MultinomialNB(alpha=0.1)) # 朴素贝叶斯,alpha 是平滑参数 ]) pipeline.fit(X_train, y_train) y_pred = pipeline.predict(X_test) print(classification_report(y_test, y_pred, target_names=['Ham', 'Spam']))ngram_range=(1, 2)表示同时考虑单个词和相邻的两个词组合。比如“free”和“free entry”都会被当作特征。min_df=2过滤掉只出现一次的词,减少噪声。max_df=0.95过滤掉几乎每篇文档都有的词,这些词没有区分能力。alpha=0.1是拉普拉斯平滑的变体,防止某个词在训练集里没出现过导致概率为 0。
朴素贝叶斯在文本分类上往往能打败更复杂的模型,因为它的独立性假设在词袋模型下反而成了一种有效的正则化。如果你换成逻辑回归,可能需要更仔细地调正则化参数。
4.3 两个项目的交叉验证与模型持久化
小样本项目一定要做交叉验证,而且最好用分层 K 折。模型训练完之后,用joblib保存下来,下次直接加载,不用重新训练。
# model_persist.py # 模型保存与加载 import joblib from sklearn.model_selection import cross_val_score # 交叉验证 cv_scores = cross_val_score(pipeline, X_train, y_train, cv=5, scoring='f1') print(f"F1 分数:{cv_scores.mean():.4f} (+/- {cv_scores.std():.4f})") # 保存模型 joblib.dump(pipeline, 'spam_classifier.pkl') print("模型已保存") # 加载模型 loaded_model = joblib.load('spam_classifier.pkl') new_message = ["Congratulations! You've won a free ticket"] prediction = loaded_model.predict(new_message) print(f"预测结果:{'Spam' if prediction[0] == 1 else 'Ham'}")用 F1 分数而不是准确率来评估,是因为垃圾短信数据通常是不平衡的,正常短信远多于垃圾短信。如果只看准确率,一个把所有短信都判为正常的模型也能到 85% 以上,但它的 F1 分数会很低。joblib比pickle更适合保存 sklearn 模型,因为它在处理 numpy 数组时效率更高。
5. 避坑指南:四个项目里最容易翻车的五个地方
5.1 标准化时用全量数据 fit
现象:模型在测试集上表现异常好,但上线后效果一落千丈。原因:在划分训练集和测试集之前就做了标准化,导致测试集的统计信息泄露到了训练过程中。解决:永远先划分数据集,再在训练集上fit,测试集只做transform。这个坑在鸢尾花和声呐数据上尤其致命,因为它们的特征尺度差异大,标准化对结果影响明显。
5.2 小样本数据集不做分层抽样
现象:某次运行结果特别好,换一个随机种子结果掉十几个百分点。原因:train_test_split默认是随机抽样,在小样本上可能导致某个类别在测试集里几乎没有样本。解决:加上stratify=label参数,保证训练集和测试集的类别比例一致。声呐数据只有 208 个样本,不做分层的话,测试集里可能只有三五个正样本,评估结果完全没有参考价值。
5.3 文本分类时在划分前做 TF-IDF
现象:交叉验证分数很高,但实际预测新短信时效果很差。原因:TF-IDF 的fit过程会统计词频和逆文档频率,如果在全量数据上做,测试集的词汇信息就泄露了。解决:用Pipeline把向量化和分类器串起来,这样交叉验证时fit只会作用在训练折上。我见过有人手动做 TF-IDF 然后交叉验证,分数虚高得离谱。
5.4 手写数字识别不做像素归一化
现象:模型训练 loss 震荡严重,收敛很慢。原因:原始像素值范围是 0-255,输入到神经网络后梯度太大。解决:把像素值除以 255 归一化到 0-1 之间,或者做标准化。这个操作在图像任务里几乎是必须的,但很多人从表格数据转过来会忽略。
5.5 声呐分类盲目追求复杂模型
现象:用了深度网络,训练集准确率 100%,测试集只有 65%。原因:60 个特征、208 个样本,模型参数量远大于样本量,过拟合不可避免。解决:从简单的线性模型开始,逐步增加复杂度。逻辑回归加 L2 正则化在声呐数据上通常能到 80% 左右,SVM 调好参数能到 85%。如果这些简单模型都做不好,换深度网络只会更糟。
6. 把四个项目串成一条可复用的分类任务流水线
四个项目做下来,你应该已经发现它们共享同一套流程:加载数据、划分数据集、预处理、选模型、训练、评估、调参。区别只在于数据形态不同,导致预处理和模型选型有差异。我一般会把这套流程固化成一个模板,新任务来了直接套。
# template.py # 通用分类任务模板 def run_classification(X, y, preprocess_fn, model, test_size=0.2): """ X: 特征矩阵 y: 标签向量 preprocess_fn: 预处理函数,接收 X_train, X_test,返回处理后的数据 model: 分类器实例 """ # 1. 划分 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=test_size, random_state=42, stratify=y ) # 2. 预处理 X_train, X_test = preprocess_fn(X_train, X_test) # 3. 训练 model.fit(X_train, y_train) # 4. 评估 from sklearn.metrics import classification_report y_pred = model.predict(X_test) print(classification_report(y_test, y_pred)) return model这个模板的价值在于,它强迫你把预处理和模型训练分开考虑。很多人写代码时把这两步混在一起,导致交叉验证没法做,因为预处理会泄露信息。把preprocess_fn作为参数传进来,就能保证每次交叉验证时预处理只在训练折上 fit。
最后一个技巧:用sklearn.pipeline.Pipeline把预处理和模型串起来,然后直接对 pipeline 做GridSearchCV。这样调参的时候,预处理参数和模型参数可以一起搜,而且不会泄露信息。
from sklearn.pipeline import Pipeline from sklearn.model_selection import GridSearchCV from sklearn.preprocessing import StandardScaler from sklearn.svm import SVC pipe = Pipeline([ ('scaler', StandardScaler()), ('svm', SVC()) ]) param_grid = { 'svm__C': [0.1, 1, 10], 'svm__gamma': ['scale', 'auto', 0.01], 'svm__kernel': ['rbf', 'linear'] } grid = GridSearchCV(pipe, param_grid, cv=5, scoring='f1', n_jobs=-1) grid.fit(X_train, y_train) print(f"最佳参数:{grid.best_params_}") print(f"最佳分数:{grid.best_score_:.4f}")n_jobs=-1表示用所有 CPU 核心并行搜索。scoring='f1'在不平衡数据上比准确率更可靠。grid.best_params_里的参数名带svm__前缀,是因为它们在 pipeline 里的步骤名是svm。
我自己做项目的习惯是:每接触一个新数据集,先用最简单的模型跑一个基线,记录下准确率和 F1。然后逐步增加复杂度,每次只改一个地方,观察指标变化。如果复杂模型没有明显提升,就退回简单模型。这个习惯帮我省了很多时间,也避免了很多“为了复杂而复杂”的翻车。希望帮到你。
本文还有配套的精品资源,点击获取