“猫型还是狗型”这个争论,在角色图鉴类内容里几乎每隔一段时间就会冒出来一次。有人看耳朵,有人看尾巴,有人看脸型,最后往往是谁声音大谁赢。与其靠印象投票,不如把能拿到的特征整理成一张表,用 Python 把数量、均值、分布统统算一遍,再顺手做一个简单的分类模型,让代码给出一个可复现的参考结论。本文就围绕这个趣味问题,完整走一遍“数据表设计 → pandas 汇总统计 → matplotlib 可视化 → scikit-learn 二分类”的流程。文中代码均可直接复制运行,示例数据为演示用的合成数据,重点在于把统计与分类的方法讲透,你可以替换成自己手里的真实数据。
1. 背景与核心概念
1.1 “猫型”与“狗型”本质上是一个分类问题
先把概念说清楚。所谓“猫型”和“狗型”,在角色设定语境里并不是官方给出的严格分类字段,而是玩家社区根据外形特征、行为习惯、动作设计总结出来的一种趣味贴标签方式。猫型往往对应体型轻巧、动作敏捷、独立性强的形象;狗型往往对应体型偏大、力量突出、忠诚亲和的形象。
从技术角度看,这就是一个典型的二分类问题(Binary Classification):给定一个样本的若干特征,判断它属于两个类别中的哪一个。特征是身高、体重、速度、攻击这类可量化的数值;类别标签就是cat和dog两个字符串。理解了这一点,后面的统计和建模就都有了落脚点。
需要注意的是,“猫型 / 狗型”与“属性类型”是两个不同维度的概念。属性(如普通、火、电)描述的是能力来源,猫狗描述的是形象归类。两者可以做交叉分析,但不能混为一谈。本文的示例表里会同时保留这两个字段,正是为了演示交叉表怎么用。
1.2 为什么用代码来做这件事
人工数一遍几十条数据当然也能数完,但代码有三个明显优势。
第一是可复现。统计口径一旦写成代码,换一批数据重新跑一遍,结论的口径完全一致,不会出现“这次我按体重算、上次我按体长算”的偏差。
第二是可扩展。示例数据只有二十几条,真实图鉴可能有几百上千条,人工统计容易出错,代码则是线性扩展。
第三是能算得更深。除了“谁多谁少”,我们还可以算均值差异、属性分布交叉、特征相关性,甚至训练一个模型来判断新角色更偏向哪一类。这些都不是手算能轻松完成的。
标题里的sum,在这里可以理解为“汇总求和”——把各类别、各属性的数据加总起来横向比较。这也是本文第 4 节的核心内容。
1.3 本文涉及的三个技术点
整篇文章会围绕三个技术点展开,它们各自独立又互相衔接:
- pandas 数据汇总:读取 CSV、分组(
groupby)、计数(value_counts)、求和(sum)、求均值(mean)、交叉表(crosstab)、透视表(pivot_table)。 - matplotlib 可视化:柱状图、饼图、散点图,以及最容易被忽略的中文字体配置。
- scikit-learn 分类建模:特征标准化、训练集测试集划分、KNN 分类器、准确率评估、概率预测。
三个技术点覆盖了数据分析项目的常规链路,学完之后这套模板可以迁移到很多类似场景,比如商品分类、用户分群、内容打标。
2. 环境准备与版本说明
2.1 运行环境与依赖
本文示例在常规的 Python 3 环境下即可运行,操作系统不做限制,Windows、macOS、Linux 都能跑。所需的第三方库有三个:
| 库 | 作用 | 说明 |
|---|---|---|
| pandas | 数据读取与汇总统计 | 核心数据处理库 |
| matplotlib | 图表绘制 | 负责可视化输出 |
| scikit-learn | 机器学习建模 | 提供 KNN、标准化、评估工具 |
具体的版本号本文不写死,因为不同时间点安装到的版本会有差异。建议的做法是:先确认 Python 版本在 3.8 以上,然后直接安装较新的稳定版。如果你的项目已有依赖约束,以项目requirements.txt为准,避免为了跑一个示例把整个环境升级乱掉。
2.2 安装命令
推荐使用虚拟环境隔离,避免污染全局环境。下面是通用命令:
# 创建虚拟环境(可选,但强烈建议) python -m venv venv # Windows 激活 venv\Scripts\activate # macOS / Linux 激活 source venv/bin/activate # 安装依赖 pip install pandas matplotlib scikit-learn如果国内网络拉取较慢,可以配置镜像源,这一点按你所在环境的实际情况处理即可。安装完成后可以用下面的命令快速自检:
python -c "import pandas, matplotlib, sklearn; print(pandas.__version__, matplotlib.__version__, sklearn.__version__)"只要能打印出三个版本号,就说明环境正常。
2.3 项目目录结构
本文示例采用如下目录结构,结构简单但足够清晰,方便后续扩展:
cat-dog-pokemon/ ├── data/ │ └── characters.csv # 示例数据 ├── scripts/ │ ├── analyze.py # 汇总统计 │ ├── visualize.py # 可视化 │ └── classify.py # 分类模型 ├── output/ │ ├── count_bar.png │ ├── pie.png │ └── scatter.png └── README.mddata放数据,scripts放脚本,output放生成物,这是一个很通用的划分方式。养成把输入、代码、输出分开的习惯,后期排查问题时能省很多时间。
3. 数据结构设计:先把表建对
3.1 字段设计思路
数据分析里有一句话:表建对了,后面就顺了。字段设计要考虑两件事——一是能不能支撑你要回答的问题,二是后续建模能不能直接用。
针对“猫型还是狗型”这个问题,我们至少要能回答:各类别有多少个?各类别的平均体型差多少?不同属性在两类之间的分布是否有差异?新角色更接近哪一类?
据此设计的字段如下:
| 字段名 | 类型 | 含义 |
|---|---|---|
| id | 整数 | 编号,唯一标识 |
| name | 字符串 | 角色名称 |
| category | 字符串 | 标签,取值为 cat 或 dog |
| type1 | 字符串 | 主属性,如 normal、fire |
| height_m | 浮点 | 身高,单位米 |
| weight_kg | 浮点 | 体重,单位千克 |
| hp | 整数 | 体力值 |
| attack | 整数 | 攻击值 |
| speed | 整数 | 速度值 |
这里把数值型特征统一成可比较的量纲很重要。如果身高的单位有人写厘米、有人写米,后面算均值就会得到毫无意义的数字。数据表设计阶段就要把单位统一写进字段名,这是一种自解释的做法。
3.2 示例数据说明
需要特别强调:下面这份数据是为了演示流程而构造的合成数据,角色名称使用通用代号,不代表任何真实图鉴设定。它的作用是让代码能跑起来、让统计口径能演示清楚。你在实际使用时,请替换成自己合法获取的数据源。
id,name,category,type1,height_m,weight_kg,hp,attack,speed 1,示例角色01,cat,normal,0.4,4.2,45,49,45 2,示例角色02,cat,normal,0.5,6.0,60,62,60 3,示例角色03,cat,electric,0.4,6.0,35,55,90 4,示例角色04,cat,psychic,0.3,3.2,40,45,55 5,示例角色05,cat,fairy,0.3,3.0,38,45,53 6,示例角色06,cat,dark,0.4,4.5,44,50,65 7,示例角色07,cat,grass,0.4,5.0,50,55,45 8,示例角色08,cat,normal,0.3,2.8,35,40,75 9,示例角色09,cat,ice,0.4,5.5,55,50,40 10,示例角色10,cat,fire,0.5,8.0,65,70,70 11,示例角色11,cat,ghost,0.3,2.5,30,35,85 12,示例角色12,cat,steel,0.4,9.0,60,65,50 13,示例角色13,dog,normal,0.7,14.0,65,70,45 14,示例角色14,dog,fighting,0.8,18.0,75,80,60 15,示例角色15,dog,fire,0.9,25.0,80,95,55 16,示例角色16,dog,rock,0.6,30.0,70,85,35 17,示例角色17,dog,electric,0.8,20.0,70,80,90 18,示例角色18,dog,water,1.0,28.0,85,75,50 19,示例角色19,dog,ground,1.1,35.0,90,100,40 20,示例角色20,dog,dark,0.9,22.0,75,90,65 21,示例角色21,dog,normal,0.6,12.0,55,60,70 22,示例角色22,dog,ice,0.8,24.0,78,72,48 23,示例角色23,dog,fairy,0.7,16.0,68,62,58 24,示例角色24,dog,poison,0.9,21.0,72,78,52把这段内容保存为data/characters.csv,注意编码选择 UTF-8,避免中文列名或名称出现乱码。
3.3 为什么数据量少也能做演示
有同学会问:24 条数据训练模型有意义吗?答案是——对演示流程有意义,对得出可靠结论意义不大。
本文使用小样本,主要目的是让整条链路能在本地几秒钟内跑通,方便你验证环境、理解每一步的作用。真实项目中,如果你要得到一个有说服力的分类器,样本量通常需要达到数百甚至更多,并且要保证两个类别的样本尽量均衡。这个前提在后面的最佳实践里还会再提。
4. pandas 分组统计:把 sum 算明白
4.1 读取数据与基础检查
先把数据读进来,做几项基础检查:形状、前几行、各列类型、缺失值情况。这几步是标准动作,建议每次都做,能提前发现很多低级问题。
# 文件路径:scripts/analyze.py import pandas as pd DATA_PATH = "data/characters.csv" df = pd.read_csv(DATA_PATH, encoding="utf-8") print("数据形状:", df.shape) print("\n前 3 行:") print(df.head(3)) print("\n各列类型:") print(df.dtypes) print("\n缺失值统计:") print(df.isna().sum())预期输出大致如下(具体数值以你的数据为准):
数据形状: (24, 9) 前 3 行: id name category type1 height_m weight_kg hp attack speed 0 1 示例角色01 cat normal 0.4 4.2 45 49 45 1 2 示例角色02 cat normal 0.5 6.0 60 62 60 2 3 示例角色03 cat electric 0.4 6.0 35 55 90df.shape返回(24, 9),表示 24 行 9 列。如果这里打印出来的形状和你预期不一致,通常是 CSV 里有空行、或者分隔符不是逗号,需要先排查数据文件本身。
4.2 计数、求和与均值
接下来做最核心的汇总。这里要区分三个概念,很多初学者容易混淆:
- count(计数):某一类有多少条记录。
- sum(求和):某一类某个数值字段的总和。
- mean(均值):总和除以条数,反映“个体平均水平”。
三者用在哪?如果你想回答“哪一类更多”,看 count;如果你想回答“哪一类总体力量更强”,可以看 sum,但 sum 会被数量影响,所以更严谨的做法是看 mean。下面代码把三种都算一遍。
# 文件路径:scripts/analyze.py(续) # 1. 类别计数 count_by_cat = df["category"].value_counts() print("各类别数量:") print(count_by_cat) # 2. 按类别求和 sum_by_cat = df.groupby("category")[["hp", "attack", "speed", "weight_kg"]].sum() print("\n各类别属性总和:") print(sum_by_cat) # 3. 按类别求均值(保留两位小数) mean_by_cat = df.groupby("category")[["height_m", "weight_kg", "hp", "attack", "speed"]].mean().round(2) print("\n各类别属性均值:") print(mean_by_cat)groupby("category")的作用是按 category 这一列把数据切成若干组,后面的聚合函数会在每组内部独立计算。[["hp", "attack", ...]]这种双括号写法表示选出多列,返回的是一个 DataFrame;如果只选一列,返回的是 Series。
运行后你会看到类似这样的均值表:
height_m weight_kg hp attack speed category cat 0.38 5.31 48.08 52.58 61.08 dog 0.82 21.25 72.50 77.58 52.75从这张表能读出一些有意思的差异:猫型平均身高和体重明显更小,速度均值更高;狗型则在体力、攻击上有优势。这正好印证了前面说的形象设定差异——轻巧敏捷对厚重力量。
这里再补充一个容易踩坑的点:mean()默认会跳过 NaN,但如果某一列整列都是文本,pandas 会直接把它排除在计算之外而不报错。所以看到结果少了几列时,先检查列的数据类型是不是被误读成了字符串。
4.3 交叉表与透视表
如果只看类别之间的整体差异还不够,可以进一步看“属性 × 类别”的分布。pd.crosstab用来做频数交叉表,pivot_table用来做带聚合函数的透视表,两者都是分析阶段的常用工具。
# 文件路径:scripts/analyze.py(续) # 属性与类别的交叉频数表 cross = pd.crosstab(df["type1"], df["category"], margins=True, margins_name="合计") print("属性 × 类别 交叉表:") print(cross) # 透视表:按类别看速度均值 pivot = df.pivot_table( index="category", values=["hp", "attack", "speed"], aggfunc="mean" ).round(2) print("\n透视表:") print(pivot)margins=True会在最后追加一行一列合计,margins_name="合计"则把默认的All改成中文,输出更易读。
交叉表的价值在于快速发现分布是否均衡。比如某个属性只出现在猫型里、另一个只出现在狗型里,那这个属性在分类模型里就会变成非常强的信号。这类“单一取值主导”的特征在真实项目中需要特别警惕,因为它可能带来过拟合。
4.4 结果解读的注意事项
拿到统计结果后,有三个解读原则值得记住。
第一,小样本的均值不稳定。24 条数据里某一类只有 12 条,均值很容易被一两个极端值拉偏。看均值的同时最好看一眼中位数和最大值最小值。
第二,相关不等于因果。统计出“狗型平均攻击更高”,只能说明在这份数据里存在这个现象,不能推导出“因为它是狗型所以攻击高”。
第三,口径要写进结论。写分析报告时,最好注明“基于 N 条样本、采用算术平均值”,这样别人才知道你的数字是怎么来的。
如果需要看中位数和极值,可以这样补一段:
# 文件路径:scripts/analyze.py(续) describe = df.groupby("category")["speed"].describe().round(2) print("速度分布描述性统计:") print(describe)describe()会一次性给出 count、mean、std、min、25%、50%、75%、max,非常适合快速了解一个字段的分布形态。
5. 可视化:让结论一眼能看懂
5.1 中文显示配置(最容易踩的坑)
matplotlib 默认字体不含中文字形,直接画中文标题会显示成一堆方块。这是新手最高频的问题之一。解决办法是显式指定一个系统中存在的中文字体。
# 文件路径:scripts/visualize.py import matplotlib matplotlib.use("Agg") # 无图形界面环境下必须,生成本地图片 import matplotlib.pyplot as plt import pandas as pd # 按优先级尝试多个候选字体 plt.rcParams["font.sans-serif"] = [ "SimHei", # Windows 常见 "Microsoft YaHei", # Windows 常见 "PingFang SC", # macOS 常见 "WenQuanYi Zen Hei",# Linux 常见 "DejaVu Sans" # 兜底,不含中文 ] plt.rcParams["axes.unicode_minus"] = False # 解决负号显示为方块 DATA_PATH = "data/characters.csv" df = pd.read_csv(DATA_PATH, encoding="utf-8")matplotlib.use("Agg")指定使用不依赖显示器的后端,适合在服务器、Docker、CI 环境中运行。如果你在本地 Jupyter 里调试,可以不加这一行。
axes.unicode_minus = False解决的是负号渲染问题。当坐标轴出现负数刻度时,某些字体会把减号显示成方块,这个配置能规避掉。
如果配置完仍然显示方块,说明列表里的字体在你的系统上都不存在。这时可以用下面的代码查一下系统里有哪些可用字体,再替换进去:
from matplotlib import font_manager names = sorted({f.name for f in font_manager.fontManager.ttflist}) print([n for n in names if "Hei" in n or "Song" in n or "YaHei" in n])5.2 柱状图与饼图
柱状图适合比较数量大小,饼图适合展示占比结构。两者放在一张画布上对照,信息更完整。
# 文件路径:scripts/visualize.py(续) count_by_cat = df["category"].value_counts() fig, axes = plt.subplots(1, 2, figsize=(12, 4)) # 左图:数量柱状图 count_by_cat.plot(kind="bar", ax=axes[0], color=["#4C9BE8", "#F2A65A"]) axes[0].set_title("猫型 / 狗型 数量对比") axes[0].set_xlabel("类别") axes[0].set_ylabel("数量") axes[0].tick_params(axis="x", rotation=0) # 右图:占比饼图 axes[1].pie( count_by_cat.values, labels=count_by_cat.index, autopct="%1.1f%%", startangle=90 ) axes[1].set_title("占比分布") plt.tight_layout() plt.savefig("output/count_bar.png", dpi=150, bbox_inches="tight") plt.close(fig)几个参数解释一下:figsize=(12, 4)控制画布宽高,单位是英寸;dpi=150控制输出分辨率,数值越大图越清晰、文件也越大;bbox_inches="tight"会裁掉多余白边,避免文字被截断。
plt.close(fig)这一行经常被忽略。在批量生成多张图时,如果不关闭,图形对象会一直占用内存,跑几百张图后可能直接把内存撑满。
5.3 散点图看两类分布
散点图最适合观察两个数值特征在两类之间是否可分离。如果两类点在图上的位置明显分开,说明这两个特征对分类很有用;如果混在一起,说明单靠这两个特征很难区分。
# 文件路径:scripts/visualize.py(续) fig, ax = plt.subplots(figsize=(8, 6)) for cat, group in df.groupby("category"): ax.scatter( group["weight_kg"], group["speed"], label=cat, alpha=0.75, s=70 ) ax.set_xlabel("体重(kg)") ax.set_ylabel("速度") ax.set_title("体重与速度分布散点图") ax.legend() ax.grid(alpha=0.3) plt.tight_layout() plt.savefig("output/scatter.png", dpi=150, bbox_inches="tight") plt.close(fig)for cat, group in df.groupby("category")这种写法在画分组图时非常好用,比手动筛两次再画要简洁得多。alpha控制透明度,重叠的点也能看清密度;s控制点的大小。
在示例数据上,你大概率会看到左下角密集的是猫型(体重轻、速度快),右上角散开的是狗型(体重大、速度相对低)。这种视觉上的分离度,正好可以反过来验证第 6 节的模型效果是否合理。
6. 实战:用 KNN 做一个猫狗分类小模型
6.1 特征选择与标准化的必要性
建模之前先想清楚用哪些特征。本文选height_m、weight_kg、hp、attack、speed这五个数值列作为特征,category作为标签。注意绝对不能把id、name放进特征——id是唯一标识,和类别没有因果关系;name是文本,且在我们的数据里和类别高度绑定,放进去等于直接泄露答案。
标准化是这一步的关键。原因是:身高单位是米,数值在 0.3 到 1.1 之间;体重在 2.5 到 35 之间;速度在 35 到 90 之间。量纲差异很大,而 KNN 是基于距离计算的算法,距离会被数值大的特征完全主导。标准化的作用就是把每个特征缩放到均值 0、方差 1 的尺度上,让各特征权重平等。
# 文件路径:scripts/classify.py import pandas as pd from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.neighbors import KNeighborsClassifier from sklearn.metrics import accuracy_score, classification_report DATA_PATH = "data/characters.csv" df = pd.read_csv(DATA_PATH, encoding="utf-8") FEATURES = ["height_m", "weight_kg", "hp", "attack", "speed"] TARGET = "category" X = df[FEATURES].values y = df[TARGET].values print("特征矩阵形状:", X.shape) print("标签分布:", pd.Series(y).value_counts().to_dict())6.2 划分训练集与测试集
训练集用于“学习”,测试集用于“考试”。如果两者混在一起评估,模型相当于提前看过答案,评估结果没有参考价值。
stratify=y的作用是按类别比例分层抽样,保证训练集和测试集里两个类别的占比一致。在小样本场景下这个参数尤其重要,否则可能出现测试集里全是同一类的情况。
# 文件路径:scripts/classify.py(续) X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.25, random_state=42, stratify=y ) print("训练集样本数:", len(X_train)) print("测试集样本数:", len(X_test))random_state=42固定随机种子,保证每次运行结果一致,便于复现和排查。这个数值本身没有特殊含义,取任何整数都可以,关键是同一个项目里统一。
需要提醒:stratify在某个类别的样本数少于 2 时会报错,因为无法在两个子集里都分出该类。如果你的数据里存在“稀有类别”,要么去掉stratify,要么先补充样本。
6.3 训练与评估
KNN 的核心思想很朴素:看一个样本周围最近的 K 个邻居分别属于哪一类,然后少数服从多数。它没有显式的“训练过程”,fit阶段只是把训练数据存起来,预测时才真正计算距离。
# 文件路径:scripts/classify.py(续) scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_test_scaled = scaler.transform(X_test) # 注意:只用 transform,不要 fit k = min(5, len(X_train)) model = KNeighborsClassifier(n_neighbors=k) model.fit(X_train_scaled, y_train) y_pred = model.predict(X_test_scaled) print("K 值:", k) print("准确率:", round(accuracy_score(y_test, y_pred), 4)) print("\n分类报告:") print(classification_report(y_test, y_pred, zero_division=0))这里有一个非常经典的错误需要强调:测试集只能用transform,绝对不能用fit_transform。如果对测试集调用fit_transform,就会用测试集的均值和方差重新计算,相当于让模型提前知道了测试数据的分布,这属于数据泄露,评估结果会虚高。
k = min(5, len(X_train))是一个防御性写法。当训练样本少于 5 个时,直接传 5 会报错,取最小值可以避免这个边界问题。
classification_report会给出精确率、召回率、F1 值。样本量小的时候,准确率的波动会很大,比如 6 个测试样本错 1 个,准确率就从 100% 掉到 83.3%。所以小样本下不要过度解读这几个数字,重点看流程是否跑通、代码逻辑是否正确。
6.4 用模型做一次预测
模型训练好之后,可以输入一个新角色的特征,看它被判成哪一类,以及两类各自的概率。
# 文件路径:scripts/classify.py(续) new_sample = [[0.5, 7.0, 50, 55, 80]] # 身高、体重、hp、攻击、速度 new_scaled = scaler.transform(new_sample) proba = model.predict_proba(new_scaled)[0] print("预测结果:", model.predict(new_scaled)[0]) print("各类概率:") for name, p in zip(model.classes_, proba): print(f" {name}: {p:.2%}")predict_proba返回的是每个类别的概率,顺序和model.classes_一致。用zip配对打印可以避免看错顺序。这种“给出概率而不是只给结论”的做法在实际业务中很常见,因为决策者往往需要知道把握有多大。
如果模型给出的概率接近 50% 对 50%,说明这个新样本的特征在两类之间比较模糊,属于典型的“边界样本”。这类样本在真实场景里往往需要人工复核,而不是完全交给模型。
7. 常见问题与排查思路
实操过程中遇到的问题,大部分集中在数据读取、中文显示和样本划分三块。下面这张表覆盖了最高频的几类。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
FileNotFoundError | 相对路径与运行目录不一致 | 确认在项目根目录执行,或改用绝对路径 |
| 中文显示成方块 | 未配置中文字体 | 设置font.sans-serif并清除字体缓存 |
UnicodeDecodeError | 文件编码与读取参数不匹配 | 显式指定encoding="utf-8"或encoding="gbk" |
KeyError: 'category' | 列名带空格或大小写不一致 | 用df.columns = df.columns.str.strip()清洗 |
| 数值列被当成字符串 | 数据里有非数字字符或空值 | 用pd.to_numeric(..., errors="coerce")转换 |
ValueError: The least populated class... | 某类别样本太少,无法分层 | 去掉stratify或补充样本 |
| 准确率异常接近 1.0 | 特征里混入了标签相关信息 | 检查是否把标签列或 id 放进了特征 |
| 图表保存为空白 | 未调用savefig或画布被提前关闭 | 确认savefig在close之前 |
挑几个典型问题展开说一下排查思路。
路径问题是最常见的。很多人是在scripts/目录下执行python analyze.py,此时工作目录是scripts/,data/characters.csv自然找不到。解决方式有两种:一是在项目根目录用python scripts/analyze.py执行;二是在代码里用pathlib基于文件位置拼出绝对路径。后者更稳健:
from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent DATA_PATH = BASE_DIR / "data" / "characters.csv"列名带空格的问题非常隐蔽。从 Excel 导出 CSV 时,表头有时会带不可见的空格或 BOM 字符,导致df["category"]报 KeyError。排查时先打印df.columns.tolist(),肉眼看不出来就用repr()打印,空格会显示出来。统一的清洗写法是:
df.columns = df.columns.str.strip().str.replace("\ufeff", "", regex=False)准确率虚高是建模阶段最需要警惕的。除了前面提到的数据泄露,还有一种情况是小样本下测试集刚好和训练集高度相似。判断方法是:把random_state换几个不同的值分别跑一遍,如果准确率在 60% 到 100% 之间大幅波动,说明样本量不足,结论不可靠。
中文乱码还有一种情况是savefig之后图片没问题,但控制台print输出乱码。这通常和终端编码有关,Windows 下可以尝试chcp 65001切换到 UTF-8 编码页,或者在脚本开头加环境变量配置。
8. 最佳实践与工程建议
8.1 数据层面的规范
第一,字段命名统一。数值字段把单位写进名字,如weight_kg、height_m,比只写weight、height要清晰得多。后面别人接手或自己半年后回看,都能立刻明白含义。
第二,标签取值收敛。category只用cat/dog两个值,不要出现Cat、CAT、猫咪混用的情况。大小写不一致会导致value_counts()统计出多行,模型也会把它当成多个类别处理。
第三,数据版本可追溯。示例数据文件名里可以带上日期或版本,如characters_v1.csv。真实项目里更推荐用 Git 管理数据文件,或者记录数据来源与生成脚本,保证任何一次分析结果都能复现。
第四,缺失值要有明确策略。是删除、填充还是标记为特殊值,需要提前定好并写进文档。不同策略对统计结果的影响可能很大,尤其是在小样本场景下。
8.2 代码层面的规范
把数据路径、特征列表、超参数都提取为模块级常量,而不是散落在各个函数里。这样做的好处是改一处即可全局生效:
DATA_PATH = "data/characters.csv" FEATURES = ["height_m", "weight_kg", "hp", "attack", "speed"] TARGET = "category" TEST_SIZE = 0.25 RANDOM_STATE = 42函数划分上,建议把“读取数据”“统计汇总”“画图”“建模”拆成独立函数,主流程只负责调用。这样单测好写,调试也好定位:
def load_data(path): """读取并清洗数据,返回 DataFrame。""" df = pd.read_csv(path, encoding="utf-8") df.columns = df.columns.str.strip() return df def summarize(df): """输出类别计数与均值汇总。""" return df.groupby("category")[["hp", "attack", "speed"]].mean().round(2)异常处理要有针对性,不要用一个裸except兜住所有错误。读取文件时至少区分“文件不存在”和“格式错误”两种情况,分别给出明确提示,这样排查时不用猜。
日志方面,脚本类项目用print可以接受,但一旦要做成定时任务或服务,就应该换成logging,把关键步骤、耗时、样本量都记录下来。
8.3 建模层面的建议
样本均衡是首要前提。如果两个类别数量差得很悬殊,模型会倾向于全预测成多数类,准确率看着不低但没有实际意义。处理方式包括重采样、调整类别权重等,具体选哪种要看业务场景。
特征工程要有依据。不要因为某个特征能提升一点准确率就无脑加,要能解释它为什么和标签相关。本文里的体重、速度之所以有效,是因为猫狗的形象设定本身就包含了体型差异,这个解释是成立的。
交叉验证比单次划分更可靠。样本稍多之后,建议用cross_val_score做 K 折交叉验证,看准确率的均值和方差,而不是只看一次划分的结果:
from sklearn.model_selection import cross_val_score scores = cross_val_score(model, scaler.transform(X), y, cv=5) print("各折准确率:", scores.round(4)) print("平均准确率:", round(scores.mean(), 4))模型选择由简到繁。KNN 简单直观,适合入门和快速验证;数据量上来、特征维度变高之后,可以再考虑逻辑回归、决策树、随机森林等方法。不要一上来就上复杂模型,先跑通基线才是正确的顺序。
结果要能解释。在“猫型还是狗型”这类趣味场景里,给出一个分类结论的同时,最好能说明是哪些特征起了主要作用。KNN 本身解释性一般,如果业务上需要解释,可以换成决策树并输出特征重要性。
9. 总结与学习路线
回头看这篇文章的链路,其实就三件事:把数据整理成一张干净的表,用汇总统计把“谁多谁少、差多少”算清楚,再用一个简单模型把判断过程自动化。pandas 负责第一步和第二步,matplotlib 负责把结论变得直观,scikit-learn 负责第三步。这三块能力组合起来,就能覆盖相当多的日常数据分析需求。
如果你想把这条链路继续往深里走,建议按下面的顺序推进:
- 先把 pandas 的
groupby、merge、pivot_table这三个操作练熟,它们能解决八成以上的数据整理问题。 - 再补 matplotlib 和 seaborn 的常用图表,重点是分组柱状图、箱线图和热力图,这三种在对比分析里出现频率极高。
- 然后系统学一遍 scikit-learn 的流程:
train_test_split→StandardScaler→ 模型 →cross_val_score→ 调参。这套流程基本是固定的,熟练之后换任何模型都能套用。 - 最后关注数据质量本身。特征是否泄露、样本是否均衡、标注是否一致,这些问题对结果的影响往往比换模型大得多。
在真实项目中,需要优先防范的风险有三个:一是数据来源的合法性,任何分析都应基于你有权使用的数据;二是特征泄露导致评估失真,务必在建模前逐个确认特征列的含义;三是小样本下的过度解读,结论要注明样本量和口径,不要把巧合当成规律。
建议你先把文中的示例数据跑通一遍,看到图表和准确率之后,再换成自己感兴趣的数据集重新走一遍流程。真正动手跑一次,比读十遍教程都管用。如果这篇文章对你有帮助,可以收藏起来,后面做数据分析时直接拿这套模板改。