简介:面向机器学习初学者的俄罗斯刑事犯罪数据分析与预测实战包,以2008至2023年俄罗斯犯罪统计数据为基础,覆盖数据清洗、回归建模、趋势拟合、KMeans聚类等常见任务,适合希望借助真实项目掌握Python数据分析全流程的读者。压缩包共5个文件:3个py源码分别实现犯罪趋势分析、酒精与犯罪回归分析、犯罪率预测建模,另附1个readme说明文件和1个容量约589.70 KB的csv数据集;整个压缩包约107KB,文件结构清晰,按脚本顺序运行即可复现结果,readme还提供依赖模块与环境配置提示。代码基于pandas、numpy、matplotlib、scikit-learn、plotly等整理,手工调试后无语法错误,可以结合交互式图表观察犯罪率变化、酒精消费与犯罪数量之间的关系,并继续扩展为课堂作业或毕业设计模板。目前已有55人学习,适合初学机器学习的数据分析人员快速上手实践。
1. 0.6 MB 的俄罗斯刑事犯罪数据集,凭什么撑起一条完整预测流水线
解压压缩包后,最大的文件只有 589.70 KB,一眼看上去很难让人兴奋。但拿它练过手的人会明白,这种“地区+年份+犯罪类型”的面板数据,是社会统计类机器学习里最典型的素材:数据不大,脏东西不少,字段名还是俄语,预测目标却异常清晰——下一年的犯罪总量是升还是降,或者落到哪个区间。压缩包里的 3 个源代码分别覆盖数据清洗、特征工程加回归、分类预测三段主线,串起来正好是一条可以照抄的 AI 实战流水线。
它的受众也很明确:想做社会治理类数据挖掘的从业者,或者要交课程设计、手里只有公开统计数据的同学。如果你平时在 Kaggle 上跑的都是整理干净的数据,这份数据集会让你体会到编码猜谜、统计口径对齐和序列相关性这三个隐藏门槛,它们才是这类项目里最容易翻车的地方。下文就按这三份代码的顺序,把每一步的选型理由、参数设置和坑位全部摊开。
2. 读入俄罗斯犯罪统计:编码、列名与口径先对齐再谈建模
2.1 先认识这种“地区×年份”的面板结构
还没动手写代码之前,我会先花 10 分钟把数据集的物理结构摸清楚。打开压缩包里的 CSV,通常是第一行字段名,后续每一行代表某地区在某年的某类犯罪情况,中间夹杂人口、失业率、平均收入这类社会经济指标。也就是说拿到手并不是一眼就能喂给回归模型的“宽表”,而是竖装的“长表”。
这种结构在统计部门发布的数据里很常见。刑事犯罪统计一般会切成两个维度:一是行政区划,二是犯罪类型,比如盗窃、诈骗、涉毒,每个格子对应一个已登记案件数。理解“已登记”这三个字非常重要——它不等于真实发案量,中间存在犯罪黑数。这个口径如果没搞清楚,模型的边界就会失真:你只能预测“按当前统计口径,明年登记案件是升是降”,而不是真实社会治安的绝对水平。
这类数据里常见字段通常是这样一组,见表 2-1:
| 字段示例(俄语) | 中文含义 | 类型 | 建模用途 |
|---|---|---|---|
| Регион | 地区 | 字符串 | 分组键、地区特征 |
| Год | 年份 | 整数 | 排序与时间分割 |
| Тип преступления | 犯罪类型 | 字符串 | 聚合依据 |
| Число преступлений | 已登记案件数 | 数值 | 预测目标 |
| Население | 人口 | 数值 | 特征 |
| Уровень безработицы | 失业率 | 数值 | 特征 |
这个表格是一份阅读地图。拿到数据后,第一件事不是统计缺失值,而是对着这张表看每一列能不能对上号。对不上的列名需要弄清楚它是哪个统计指标,再决定留用还是剔除;对不上又不明确含义的列,最稳妥的做法是先从建模特征里拿掉,等之后回头再查。
2.2 读文件的第一关:cp1251 编码和分号分隔符
俄罗斯统计导出 CSV 有个特点:编码经常是 cp1251,而不是 UTF-8,字段分隔符有时用分号,数字里还可能夹带空格千分位。直接pd.read_csv最容易在 encoding 参数上翻车,报一个UnicodeDecodeError出来。我一般这样兜底加载:
import pandas as pd import numpy as np CSV_PATH = "./russia_crime_by_region.csv" try: df = pd.read_csv( CSV_PATH, encoding="cp1251", sep=";", thousands=" ", skipinitialspace=True, ) except UnicodeDecodeError: df = pd.read_csv( CSV_PATH, encoding="utf-8-sig", sep=";", skipinitialspace=True, ) print(df.head().to_string()) print("形状:", df.shape)逻辑说明:先尝试俄语数据最常用的 cp1251;如果解不开再用utf-8-sig兜底,这个编码能自动去掉 BOM 头。sep=";"是因为来源数据习惯用分号作为分隔符,避免数字里的逗号把字段拆碎。thousands=" "对应“1 234 567”这种以空格分隔千位数的写法,读进来能直接变成数值。
参数说明:读完之后立刻看head()输出,重点观察两处——列名是不是俄语、数值列是不是被识别成了 object。如果year列被读成字符串,那后面的排序和滞后特征全部会出问题。读文件这一步多花五分钟反复试编码,比等到特征工程阶段再发现全列是字符串去清洗要省得多。
2.3 列名映射与数值列兜底清洗
读进来之后,把所有列名统一成英文小写加下划线,后面写特征工程时就不用反复切换输入法。映射用字典一次完成,然后打印出来还剩下哪些列没被映射到:
COLUMN_MAP = { "Регион": "region", "Год": "year", "Тип преступления": "crime_type", "Число преступлений": "crime_cnt", "Население": "population", "Уровень безработицы": "unemployment", "Среднедушевой доход": "avg_income", } df = df.rename(columns=COLUMN_MAP) df.columns = [str(c).strip().lower() for c in df.columns] leftover = [c for c in df.columns if c not in COLUMN_MAP.values()] print("未映射列:", leftover)这段代码的逻辑是:先按字典改名,然后把没被映射上的列名打印出来。如果 leftover 非空,说明原始表里还有额外字段,比如“城市人口”或者“上次普查年份”,需要回头对着源文件的表头补映射。这一步是数据清洗里最常见的迭代流程。
接下来清洗数值列。俄罗斯写法里小数点用逗号,千分位用空格,直接astype(float)会抛异常:
NUM_COLS = ["crime_cnt", "population", "unemployment", "avg_income"] for col in NUM_COLS: if col not in df.columns: print(f"缺少数值列: {col}") continue df[col] = ( df[col] .astype(str) .str.replace(",", ".") .str.replace(" ", "") .astype(float) ) print(df.dtypes)字符串替换是这类数据最常用的清洗方式:先把逗号换成点,再把空格去掉,最后转 float。如果某个字段里混入了百分号,就再补一条.str.replace("%", "")。转换完之后打印dtypes,确认所有数值列已经变成 float64,这步做完,下面做特征工程才敢继续。
3. 特征工程决定预测上限:滞后变量、变化率与地区交叉特征
3.1 为什么要先做滞后特征再做回归
对这类“地区×年份”数据,特征工程的第一原则是:把过去发生的事变成可计算的量。犯罪总量本身有极强的惯性,一个地区今年的犯罪数量,与去年、前年的值高度相关。这种序列相关性如果不用滞后特征显式表示,模型就只能在静态的社会经济特征里瞎猜,很难抓住趋势。
另一个原因是样本量。589.70 KB 的数据集撑死了也就是几千行,还包含多个犯罪类型的重复行。如果不把历史滞后值加进来,每个地区每年只剩一组社会经济指标,模型很容易学成“拿均值猜均值”的模式。滞后特征是用很少的算力换取时序记忆的常规做法,尤其适合这种小型面板数据。
这里我特别强调顺序:必须先按 region、year 排序,再做groupby和shift。很多人在这一步翻车,数据没排序就分组取滞后值,出来的特征完全错位,模型指标看着还行,其实是把年份顺序打乱的假象。排序这个动作看似无关紧要,却是时序特征的生命线。
3.2 用 groupby + shift 构造犯罪总量滞后特征
df = df.sort_values(["region", "year"]).reset_index(drop=True) agg = df.groupby(["region", "year"], as_index=False).agg( total_crime=("crime_cnt", "sum"), population=("population", "max"), unemployment=("unemployment", "mean"), avg_income=("avg_income", "mean"), ) # 目标变量:下一年的犯罪总量 agg["target"] = agg.groupby("region")["total_crime"].shift(-1) # 滞后特征 for lag in [1, 2, 3]: agg[f"crime_lag{lag}"] = agg.groupby("region")["total_crime"].shift(lag) # 社会经济变量做滞后和环比变化率 for col in ["unemployment", "avg_income"]: agg[f"{col}_lag1"] = agg.groupby("region")[col].shift(1) agg[f"{col}_yoy"] = agg.groupby("region")[col].pct_change() # 丢掉没有目标值的最新一年 model_df = agg.dropna(subset=["target"]).reset_index(drop=True) print(model_df.shape) print(model_df.head())逻辑说明:groupby(["region", "year"])先把每种犯罪类型聚合成地区年度宽表,total_crime是这个地区那一年的全部已登记案件数。作为预测目标,target用shift(-1)取“下一年”的值;其它特征用shift(1),只允许使用过去信息。crime_lag1到crime_lag3就是前 1 到 3 年的犯罪总量。
参数说明:shift(-1)之后每个地区最后一行的 target 一定是缺失值,所以用dropna(subset=["target"])把最后一年删掉。滞后阶数选择上,3 期是比较稳妥的开局设置;如果数据只有 6~7 年,滞后 3 期会把可训练样本砍掉近一半,这时要果断降到 2 期。小数据集的容量决定了特征维度不能铺太大。
3.3 变化率特征如何刻画地区间的差异
除了绝对量,还需要变化率。犯罪率的波动通常受经济变化方向影响,而不是受经济绝对水平影响。我一般会在社会经济列上补一列环比:pct_change()表示这一年的数值相对上一年变化了百分之多少,含义接近“经济变好了还是变差了”。这类方向性特征对预测拐点很有帮助,因为犯罪下降往往与失业率下降同时出现,两个特征在同一时期发生方向变化,模型更容易抓住联动规律。
跨地区建模时的另一个关键点是尺度差异。大城市地区的犯罪总量天然比人口小几十倍的地区高出很多,如果模型不加地区标识,它会把所有地区混进同一个截距,地区差异就只能靠社会经济特征勉强拟合。常见做法有两条:一是加一个地区编码,让树模型自己决定是否按地区拆分;二是做地区内标准化,把犯罪总量转成相对该地区历史均值的倍数。这个数据集的地区数不算多,我通常选第一种,因为随机森林对整数编码特征比较友好,还能顺带在特征重要性里看出地区因素占多重的分量。
另外,对不平衡的地区样本还有个细节:小地区总量小、波动大,同样一个百分点误差在绝对量上的表现很差。实际使用时,可以按人口把地区分成大、中、小三档,分层看误差,不要让数据量大的地区把其它地区的表现吃掉。
4. 三份源代码全流程:从回归到分类的递进实现
4.1 源代码一:从原始 CSV 到探索性分析
第一份代码定位是“把数据弄到能看、能算”的预处理层,一般我会命名为01_load_and_profile.py。它把读文件、列映射、缺失率统计、趋势绘图整合起来,跑完就能对数据形成基本判断。
import pandas as pd import numpy as np import matplotlib.pyplot as plt CSV_PATH = "./russia_crime_by_region.csv" df = pd.read_csv(CSV_PATH, encoding="cp1251", sep=";", skipinitialspace=True) df.columns = [str(c).strip().lower() for c in df.columns] renames = { "регион": "region", "год": "year", "тип преступления": "crime_type", "число преступлений": "crime_cnt", "население": "population", "уровень безработицы": "unemployment", "среднедушевой доход": "avg_income", } df = df.rename(columns=renames) miss_rate = (df.isna().mean() * 100).round(2) print("缺失率(%):") print(miss_rate[miss_rate > 0]) sample_regions = df["region"].dropna().unique()[:5] for reg in sample_regions: sub = df[df["region"] == reg].groupby("year")["crime_cnt"].sum() plt.plot(sub.index, sub.values, label=str(reg)) plt.legend() plt.xlabel("year") plt.ylabel("total crime") plt.title("Crime trend by region") plt.show()逻辑说明:加载后先看缺失率,再看前五个地区的总犯罪量走势,确认时间跨度和缺失位置没有明显异常。groupby("year")["crime_cnt"].sum()把犯罪类型合并成总量,如果某个地区某年总量突然掉到接近 0,大概率不是犯罪消失,而是统计缺失或录入错误,该年的样本在建模时需要剔除。
参数说明:sample_regions = ...[:5]是取前五个地区,地区数量多的时候建议换成random.sample随机抽几个,避免只看到字母排序靠前的区域,影响对整体数据形态的判断。这一步没有模型参数,但画出的趋势图能帮你确认后续该用回归还是分类,以及是否存在明显的时间断点。
4.2 源代码二:特征工程加随机森林回归
第二份代码做回归任务,目标是直接预测“下一年的犯罪总量数值”。样本量只有几百到几千时,随机森林是比梯度提升树更安全的起步算法,不需要精细调学习率,对特征尺度、异常值都不太敏感。我的初始参数是 n_estimators=400、max_depth=8、min_samples_leaf=3,这是针对小样本防止过拟合最常用的组合。
import pandas as pd import numpy as np from sklearn.ensemble import RandomForestRegressor from sklearn.model_selection import TimeSeriesSplit from sklearn.metrics import mean_absolute_error model_df = pd.read_pickle("model_df.pkl") # 前一个脚本输出的建模表 FEATURES = [c for c in model_df.columns if c not in ["region", "year", "target"]] X = model_df[FEATURES].select_dtypes(include=[np.number]) y = model_df["target"] tscv = TimeSeriesSplit(n_splits=5) mae_list = [] for train_idx, test_idx in tscv.split(X): X_train, X_test = X.iloc[train_idx], X.iloc[test_idx] y_train, y_test = y.iloc[train_idx], y.iloc[test_idx] rf = RandomForestRegressor( n_estimators=400, max_depth=8, min_samples_leaf=3, max_features=0.5, random_state=42, n_jobs=-1, ) rf.fit(X_train, y_train) pred = rf.predict(X_test) mae = mean_absolute_error(y_test, pred) mae_list.append(mae) print(f"fold MAE: {mae:.4f}") print(f"MAE mean: {np.mean(mae_list):.4f} ± {np.std(mae_list):.4f}")逻辑说明:这不是随机打乱的 KFold,而是TimeSeriesSplit,它严格要求训练集年份早于测试集,避免“拿未来预测过去”造成的假高分。select_dtypes(include=[np.number])把 region 这类字符串列全部丢弃,防止 fit 直接报错。每个 fold 输出的 MAE 是在犯罪总量这个绝对量纲上的平均误差,例如预测 12000 起、实际 11800 起,MAE 就是 200。
参数说明:max_features=0.5让每棵树只用一半特征参与分裂,增加树间差异,对泛化有实际帮助。如果五个折的 MAE 标准差很大,比如从 800 一路涨到 2000,说明模型对更远年份的预测不稳定。这时候要检查是不是社会经济特征其实没什么预测力,模型只是在复读前一年的滞后值——这是这个项目最常见的失败方式。
4.3 源代码三:把回归转成分类,预测升降方向
回归给出数值,业务上更常用的是方向:明年犯罪总量会升还是降。分类版实现起来同样简单,只要把 target 和当年 total_crime 做比较,大于 0 记 1,否则记 0。这时评估指标就不能只看准确率,还需要加混淆矩阵和召回率,因为升降两类的样本量往往并不平衡。
from sklearn.linear_model import LogisticRegression from sklearn.preprocessing import StandardScaler from sklearn.metrics import accuracy_score, recall_score model_df["target_binary"] = (model_df["target"] > model_df["total_crime"]).astype(int) X2 = model_df[FEATURES].select_dtypes(include=[np.number]) y2 = model_df["target_binary"] tscv = TimeSeriesSplit(n_splits=5) acc_list, recall_list = [], [] for train_idx, test_idx in tscv.split(X2): X_train, X_test = X2.iloc[train_idx], X2.iloc[test_idx] y_train, y_test = y2.iloc[train_idx], y2.iloc[test_idx] scaler = StandardScaler().fit(X_train) X_train_s = scaler.transform(X_train) X_test_s = scaler.transform(X_test) lr = LogisticRegression( C=1.0, max_iter=1000, class_weight="balanced", random_state=42, ) lr.fit(X_train_s, y_train) pred = lr.predict(X_test_s) acc_list.append(accuracy_score(y_test, pred)) recall_list.append(recall_score(y_test, pred)) print(f"Accuracy: {np.mean(acc_list):.3f} ± {np.std(acc_list):.3f}") print(f"Recall: {np.mean(recall_list):.3f} ± {np.std(recall_list):.3f}")逻辑说明:把回归目标二值化之后,模型的含义变成“明年是否比今年更糟”。class_weight="balanced"让算法自动按类别频率上调少数类权重。StandardScaler只能 fit 在训练集上,再用同一个 scaler 去 transform 测试集,这是防数据泄漏的基本功。
参数说明:C=1.0是正则强度的倒数,数据量小时不建议调到 0.01,否则特征权重被严重压缩,模型会在训练集上呈欠拟合状态。输出里 Recall 比 Accuracy 更有业务价值——如果 80% 的样本都是下降,全预测下降也能拿 80% 准确率,只有把“上升”这个少数类挑出来,才对实际研判有增量。
5. 俄罗斯犯罪数据建模的 5 个避坑点:从数据泄漏到地区差异
5.1 模型精度虚高:测试集里塞进了未来信息
现象:随机森林在训练集和测试集上的 R² 都超过 0.98,特征重要性排第一的总是 crime_lag1,看起来模型非常成功。
原因:最常见的是用train_test_split随机分割,或者让 StandardScaler 在全体样本上 fit。犯罪数据有强趋势和自相关,随机切分导致训练集和测试集来自同一年代,数据高度相似,模型只要记住滞后值就能拿高分。
解决:全局改用TimeSeriesSplit,或者至少手动按年份划分,保证测试集年份晚于训练集年份。代码二里从第一折到最后一折,训练集永远只包含历史年份,这才是“预测未来”而不是“复述过去”。
5.2 分类准确率高,但“上升”这一类基本没抓到
现象:分类任务里 Accuracy 上了 90%,但混淆矩阵显示上升类别的召回率只有 20% 左右,模型实际上放弃了预测犯罪上升。
原因:平稳年份里下降样本显著多于上升样本,逻辑回归的损失被多数类主导,对少数类仿若无物。
解决:把class_weight="balanced"加进逻辑回归,或者在随机森林里用class_weight="balanced_subsample"。参数一加,上升类的召回率通常能从 20% 提到 40% 到 50%,但不会高太多。这是犯罪数据本身的信号上限,不是模型调参能突破的。
5.3 滞后阶数越加越多,样本被 NaN 吃掉一半
现象:把滞后阶数从 3 加到 8,模型效果没提升,打印 shape 才发现训练行数少了近一半,每个地区前 8 年数据全被 NaN 吃掉。
原因:滞后阶数每加一阶,每个地区开头就多一行 NaN,地区数量固定的情况下,记忆长度和样本量是一对直接矛盾。小数据上尤其明显。
解决:先做 lag1 和 lag3 的组合,保证每个地区至少保留 5 年以上的有效样本。样本量不足时,果断牺牲滞后阶数,而不是牺牲特征维度。后面再想占齐 8 年历史,就得去找月度或季度细粒度数据补强。
5.4 大城市样本把整体误差拉低,小地区全在翻车
现象:整体 MAE 看着挺温和,一旦按地区分开评估,人口较少地区的误差动辄翻倍,模型对这些地区的犯罪量基本是乱猜。
原因:地区之间犯罪量和人口规模悬殊,模型学到的截距偏向样本量最大的地区,小地区样本少、波动大,绝对量上根本拟合不到位。
解决:在特征里加入地区编码让模型感知地区身份,并按人口把地区分大中小三档分别评测。不要只看全量测试集上的一个总分,任何均值指标都会掩盖小地区的糟糕表现。
5.5 俄语字段类型混乱,全流程中断
现象:pd.read_csv读完后year是字符串,population含空格和逗号,某列还带着百分号,代码跑到groupby还能忍,画图时直接报类型错误,ufunc 'add' did not contain a loop。
原因:俄语统计导出的数值列写法特殊,没有在读取阶段统一做字符替换和类型转换,pandas 把它们全部识别成 object。
解决:把第 2 章的数值清洗代码放在读取之后立即执行,先替换、再转 float,然后打印 dtypes 复查一遍。读文件和清洗是同一道工序,中间不要穿插建模代码,避免带着脏数据跑完整条链路后才发现问题。
6. 把 0.6 MB 数据用到极致:验证习惯与迭代方向
数据量只有 0.6 MB,靠堆模型赢不现实,真正拉开差距的是验证设计和特征思路。
第一,把最新两年数据单独留出来当最终验证集。用前面所有年份训练,用倒数第三年调参验证,最新两年最后再碰。这个习惯在做演示项目时尤其重要——你是在给“未来数据”交代,不是给历史数据说好话。第二,回归和分类两个视角都跑一遍,不要只看单一指标。回归用 MAE 的逐年变化判断模型稳不稳定,分类用上升类的召回率判断业务价值,两者互为交叉验证。如果回归 MAE 稳定但分类召回率只有 30%,说明模型能把握趋势幅度,却抓不住拐点,下一步应该去找更细的月度数据。第三,把随机森林的特征重要性或逻辑回归的系数按绝对值排序,保存成 CSV。特征清单是数据项目里最值钱的产品,下次拿到新地区、新年份的数据,先比对变量是否齐全,再决定能不能直接复用这套流程。
我现在每次跑完一版,都会顺手把model_df存成 parquet,把每个折的指标追加到日志表,这样不管哪一环翻车,都能快速定位是清洗错了还是参数不合理,不必从零重跑。这套流程跑熟之后,0.6 MB 只是起点,换一份几十 MB 的社会经济面板数据,改改列名和编码就能再跑一遍。希望帮到你。
本文还有配套的精品资源,点击获取