简介:一套围绕俄罗斯2008—2023年刑事犯罪数据展开的AI实战资源,面向机器学习初学者、数据分析师以及想练习完整数据挖掘流程的开发者。压缩包共5个文件:3个Python源码、1个说明文档和1个CSV数据集,整体约107KB,解压后的CSV数据文件约589.70KB。代码手工整理、无语法错误,可直接运行,覆盖犯罪趋势可视化、酒精消费与犯罪回归分析、犯罪率走势建模等典型任务;数据集含2008至2023年俄罗斯犯罪统计字段,适合作为pandas、seaborn、RandomForest、KMeans、scipy与plotly等库的练手材料。三个脚本各有侧重:一个分析年度犯罪趋势,一个做酒精与犯罪的回归拟合,另一个综合建模与预测;说明文档对运行环境和操作流程做了补充说明。目前已有56人学习下载,适合希望快速获得可复现代码并理解数据清洗、特征探索、模型训练与评估完整闭环的读者。
1. 一个 589 KB 的数据包,凭什么能预测刑事案件走向
做数据分析的同行应该都有过这种时刻:手上拿到一个压缩包,里面躺着一份数据集和三四个脚本,文件名写着“AI实战-俄罗斯的刑事犯罪数据集分析预测实例”,589 KB 的大小让人既放心又怀疑——放心的是数据量不大、跑起来不费劲,怀疑的是这么点数据真能训练出能用的预测模型?
我的答案是能,但前提是别把预期摆得太高。这类数据集的典型体量在几千到几万条记录之间,字段通常覆盖案发时间、地点、犯罪类型、受害人或嫌疑人的部分属性。用树模型或集成模型做分类、预测下一时段的高发犯罪类型、评估区域风险等级,完全够用。你要是想直接上深度学习,那一千行样本只会让你收获一堆过拟合的教训。这篇文章就按这类 zip 实战包的常见结构,把解压、清洗、特征工程、模型训练到预测的完整链路拆开讲,新手能照着跑通,熟手可以跳过基础部分直接看避坑章节。这套流程里踩过的坑,比数据集本身值钱。
2. 拆开 zip 先别急:搞清数据结构和目标变量再动手
2.1 俄罗斯刑事犯罪数据集的常见字段结构与三种形态
这类数据集的字段设计通常服务于执法统计或犯罪学研究,拿到手先别急着写 pd.read_csv,你需要花十分钟做一件事:把列名列出来,逐个问“这列能不能作为特征、会不会泄漏标签”。
我处理过类似结构的犯罪数据集,最常见的字段包括:案发日期与时间(可能拆成 date 和 time 两列)、行政区或城市名、犯罪类型( theft、robbery、murder、fraud 等英文或俄语转写)、是否侦破、受害人年龄性别、嫌疑人特征描述、案发地点经纬度、天气状况,有时还有一条简短的案情描述文本。589 KB 的压缩包解压后通常是 CSV 或 Excel 格式,行数大概在五千到两万之间,列数在十到二十列左右。
拿到数据后第一件事是统计缺失率和类别分布,不是画图,是先用代码搞清楚基本面。常见做法是先跑一个 df.info() 和 df.describe(include='all'),这两个命令能让你知道哪些列是数值型、哪些是类别型、哪些列几乎全是空值——空值超过 70% 的列直接考虑丢弃,别后期费劲填补。
2.2 解压与初步探查:第一个源代码脚本该干什么
解压这件事看起来没技术含量,但 zip 密码、中文文件名乱码、编码格式三个问题会轮流折磨你。这里给一个通用的解压代码,兼容密码和乱码场景:
import zipfile import os zip_path = "russian_crime_dataset.zip" extract_dir = "./crime_data" os.makedirs(extract_dir, exist_ok=True) with zipfile.ZipFile(zip_path, "r") as zf: for member in zf.namelist(): # 处理中文或特殊字符文件名乱码,统一用 UTF-8 重新编码 try: decoded_name = member.encode("cp437").decode("utf-8") except (UnicodeDecodeError, AttributeError): decoded_name = member target_path = os.path.join(extract_dir, decoded_name) # 防止 zip slip 路径穿越 if not target_path.startswith(os.path.abspath(extract_dir)): continue with zf.open(member) as source, open(target_path, "wb") as target: target.write(source.read()) print(os.listdir(extract_dir))这段代码解决三个问题:一是带密码的压缩包可以通过pwd=b"your_password"参数传入密码;二是老外打包时用 cp437 编码文件名,中文环境下解压会乱码,这里做了编码转换;三是 zip slip 路径穿越漏洞,保证解压文件不会跳出目标目录。如果你确定压缩包没密码没乱码,直接用zf.extractall()就好,但养成好习惯总不会错。
解压之后跑一下数据预览:
import pandas as pd df = pd.read_csv(os.path.join(extract_dir, "crime_data.csv"), encoding="utf-8") print(df.shape) print(df.columns.tolist()) print(df.head(3).to_string()) print("缺失率:") print((df.isnull().sum() / len(df)).sort_values(ascending=False).head(10))这里注意编码参数,俄语地区的数据文件经常是 cp1251 编码,你用 utf-8 读会直接报错,改成encoding="cp1251"即可。如果 csv 文件是分号分隔的,记得加sep=";"。这一步基本就是第一个源代码脚本的典型内容:解压、读取、打印基本信息。
2.3 目标变量怎么定:分类、回归还是时序预测
预测任务的定义直接决定后续模型选型。我见过三种最常见的目标设定方式:
第一种,按犯罪类型做多分类。目标列是犯罪类型,预测某条记录属于哪类犯罪。这种设定最简单,适合做入门练手,但实际业务意义有限——你不知道未来会发生什么案件,自然谈不上预测单案的类别。
第二种,预测未来时间窗口的高发犯罪类型。把数据按周或月聚合,统计每个区域、每个时间段内哪种犯罪数量最多,然后训练模型预测下一个周期的高发类型。这种设定更有业务价值,适合警力资源调度。
第三种,区域风险等级回归或分类。把区域按照发案数量划分成低风险、中风险、高风险三档,预测某个区域在下一时段的等级。
我一般建议新手直接做第二种,因为数据量小的时候聚合操作能减少噪声,而且评估指标更直观。目标列定义好了,后面所有特征工程都为这个目标服务,不要在多个目标之间摇摆。
3. 特征工程:把日期、地点、文本变成模型吃得到的数
3.1 时间特征拆解:从时间戳里挖出季节、时段和周期性
犯罪数据里最值钱的特征往往藏在时间戳里。俄罗斯高纬度地区的季节性犯罪差异非常明显,冬季夜间盗窃和暴力犯罪的比例和夏季完全不同。如果你把时间列直接作为数值特征扔给模型,模型学不到任何周期性规律,必须手动拆解。
这里给出一个我常用的时间特征拆解函数:
def engineer_time_features(df, time_col="crime_time"): df[time_col] = pd.to_datetime(df[time_col], errors="coerce") df["year"] = df[time_col].dt.year df["month"] = df[time_col].dt.month df["day_of_week"] = df[time_col].dt.dayofweek # 0=周一 df["hour"] = df[time_col].dt.hour df["is_weekend"] = df["day_of_week"].apply(lambda x: 1 if x >= 5 else 0) # 把小时切成时段,减少稀疏性 df["time_segment"] = pd.cut( df["hour"], bins=[-1, 6, 12, 18, 24], labels=["night", "morning", "afternoon", "evening"], ) # 周期性编码:用 sin/cos 把 24 小时和 12 个月映射到连续空间 df["hour_sin"] = np.sin(2 * np.pi * df["hour"] / 24) df["hour_cos"] = np.cos(2 * np.pi * df["hour"] / 24) df["month_sin"] = np.sin(2 * np.pi * df["month"] / 12) df["month_cos"] = np.cos(2 * np.pi * df["month"] / 12) return df时间特征的拆法有讲究。hour直接作为数值特征的问题是:23 点和 0 点虽然只差一小时,但在数值空间距离是 23,模型会认为它们差异很大,实际两者都是夜间犯罪高发时段。用hour_sin和hour_cos组合编码,就能把 23 点和 0 点映射到接近的位置。月份同理,1 月和 12 月相邻,数值上却相差 11。
time_segment这种分箱操作适合给树模型用,但注意分箱后类别数量少时可以直接用数字映射,不要做 one-hot,否则特征维度膨胀而样本量不够,树模型容易过拟合。我的经验是:树模型对类别特征直接 LabelEncode 就行,线性模型才需要认真处理编码方式。
3.2 地点和文本特征的降维处理:频次编码比坐标硬塞更稳
犯罪数据里的地点字段通常是行政区名、街道名或经纬度坐标。行政区名可能有三五十个类别,如果直接 one-hot 编码,特征维度瞬间爆炸,而且很多类别样本量极少,模型学不到有效信息。坐标直接作为数值特征也有问题:经纬度和犯罪率之间不是线性关系,树模型虽然能处理非线性,但两个坐标值的微小偏移对模型来说毫无意义。
我常用的处理方式有两个:
频次编码(Frequency Encoding)是首选。把每个地点在历史数据中出现的次数作为特征值,表达“这个区域是犯罪高发区还是低发区”的统计信息。这个比 LabelEncode(按字母排序给编号)保留的信息量大得多。
经纬度网格化是备选。把经纬度按 0.01 度划分网格,经纬度各除以 0.01 取整,组成“网格编号”,再对这个编号做频次编码。这样既考虑了空间邻近性,又降低了维度。
def encode_location_features(df, loc_col="district"): # 频次编码:用出现次数代表区域热门程度 freq_map = df[loc_col].value_counts().to_dict() df["district_freq"] = df[loc_col].map(freq_map) # 稀有类别合并成一个 "other" threshold = 5 rare_cats = [k for k, v in freq_map.items() if v < threshold] df[loc_col] = df[loc_col].apply(lambda x: "other" if x in rare_cats else x) return df这段代码里的threshold=5是经验值。类别样本量低于 5 的时候,频次编码出来的值基本都是个位数,模型无法区分这些低频地区之间的差异,合并成other反而让模型更稳健。如果你发现合并后模型效果变差,再把阈值调到 3 或直接不合并。
文本字段(比如案情描述)在 589 KB 的数据量下不要尝试深度学习文本表征,直接用 TF-IDF 取 Top 200 特征就够了。等你跑通基线,再考虑用预训练模型做 embedding 也不迟,那是后话。
3.3 数据分割的时序陷阱:随机切分是犯罪预测的头号杀手
这一步必须单独拿出来说。犯罪数据是典型的时间序列数据,事件发生在时间轴上,如果你用train_test_split(random_state=42)随机切分,相当于让模型用未来的数据去预测过去——训练集里出现 2023 年 1 月的数据,测试集里出现 2022 年 5 月的数据,模型学到的“规律”里混入了未来信息,评估分数虚高得离谱。
正确做法是按时间顺序切分:前 80% 的时间段做训练,后 20% 做测试。如果按照周聚合做时序预测,还要注意训练集和测试集之间要留出一个 gap(比如一周),防止时间窗口重叠导致的信息泄漏。
def temporal_split(df, date_col="crime_time", test_ratio=0.2, gap_days=7): df = df.sort_values(date_col).reset_index(drop=True) split_idx = int(len(df) * (1 - test_ratio)) train = df.iloc[:split_idx] test = df.iloc[split_idx:] # 如果做时序滑动窗口,保证训练集最后一条记录和测试集第一条记录之间有间隔 return train, test注意这个函数只做了基础切分。真正严格的做法是用 TimeSeriesSplit 做交叉验证,而不是单次切分。改天往深了做,你可以在 sklearn 里用TimeSeriesSplit(n_splits=5),每次训练集都是过去、验证集都是未来,取五次验证的平均分作为模型效果估计。样本量只有几千行的时候,这种评估方式比你用单次测试集更可靠。
4. 模型选型与训练调参:树模型优先,深度学习先放一边
4.1 为什么犯罪预测首选 LightGBM 而不是神经网络
这个选择基于三个现实:数据量小(几千行)、特征以类别为主(区域、犯罪类型、时段)、需要可解释性(业务方要知道为什么预测盗窃高发)。LightGBM 这类梯度提升树模型天然适合表格数据,类别特征处理方便、对缺失值鲁棒、训练速度快。神经网络在图像和文本上是王者,但在几千行表格数据上,基本只会给你带来过拟合和调参的深渊。
如果你是要做二分类(比如“是否会发生严重暴力犯罪”),用LGBMClassifier;做多分类预测犯罪类型,同样用LGBMClassifier但要注意类别不平衡。目标是数值型(比如预测发案数量)才考虑LGBMRegressor。
4.2 第二个源代码脚本:训练主流程与关键超参数
import lightgbm as lgb from sklearn.metrics import classification_report, roc_auc_score from sklearn.preprocessing import LabelEncoder # 准备特征和目标 feature_cols = ["month", "day_of_week", "hour_sin", "hour_cos", "district_freq", "is_weekend", "time_segment"] X = df[feature_cols].copy() y = df["crime_type_encoded"] # 类别特征要显式声明,LightGBM 才能正确处理 categorical_features = ["time_segment"] model = lgb.LGBMClassifier( n_estimators=300, learning_rate=0.05, num_leaves=31, max_depth=-1, min_child_samples=20, subsample=0.8, colsample_bytree=0.8, reg_alpha=0.1, reg_lambda=1.0, random_state=42, ) model.fit( X, y, categorical_feature=categorical_features, eval_set=[(X_test, y_test)], eval_metric="multi_logloss", callbacks=[lgb.early_stopping(50), lgb.log_evaluation(50)], )这段代码里的参数都是经过验证的起点值。num_leaves=31是 LightGBM 默认值,对应深度约 5 的树,小数据集上再加深很容易过拟合;min_child_samples=20限制了叶子节点最少样本数,这是对抗噪声的有效手段;subsample=0.8和colsample_bytree=0.8做行列采样,相当于给模型加随机性,防止每棵树都长成同一个样子。reg_alpha和reg_lambda是 L1/L2 正则,小数据集上必须加,不然特征一多就飘。
early_stopping(50)的意思是验证集 loss 连续 50 轮不下降就停止训练,防止 n_estimators 设太大导致后段过拟合。这里最容易被新手忽略的是categorical_feature参数——如果你不声明,LightGBM 会把time_segment这种字符串当成类别处理,但如果你手贱先做了 LabelEncoder,再传进去反而会被当成数值,破坏了类别之间的无序性。
4.3 类别不平衡的应对:class_weight、采样和评估指标
犯罪数据集几乎必然类别不平衡。盗窃和欺诈类案件数量可能占 60% 以上,凶杀、绑架这类严重犯罪可能只占 1%。如果你直接训练,模型会学到一个无比“聪明”的策略:把所有样本预测为盗窃类,准确率轻松上 60%,看起来效果不错,实际毫无用处。
处理不平衡有三个层次的手段:
第一层,模型内置参数。LightGBM 里设class_weight="balanced",或者按类别频率反比手动传入权重。这成本最低,见效最快。
第二层,采样。对少数类做 SMOTE 过采样,或者对多数类做下采样。但在时间序列数据上要格外谨慎——SMOTE 会在特征空间内插值生成新样本,这些样本可能落在真实时间分布之外的区域,导致评估失真。
第三层,换评估指标。不要只看 accuracy,改看宏平均 F1、AUC 或召回率。尤其是严重犯罪这类少数类,你关心的是“该抓的有没有抓到”,而不是总体准确率。classification_report里分别看每个类别的 precision 和 recall,才能真正判断模型对小类有没有区分能力。
我一般建议三个层次同时做,缺一不可。只调权重不改指标,你会被虚高的 accuracy 欺骗;只改指标不调权重,模型训练过程还是被多数类主导。
5. 避坑:犯罪数据集预测的 5 个典型翻车现场
5.1 坑一:随机切分导致未来数据“穿越”
现象:训练时在验证集上 AUC 高达 0.95,自己觉得稳了,结果部署到线上完全不能用,预测结果跟瞎猜差不多。
原因:用train_test_split随机切分,模型在训练阶段就偷看了未来的数据特征分布。犯罪数据有强时序性——某个区域的犯罪率上升趋势、季节效应、节假日前后的波动,随机切分把时间连续性完全打碎,模型学到的是跨越时间的虚假相关性。
解决:严格按时间戳排序后切分。更稳健的做法是 Purged TimeSeriesSplit:切分时从训练集尾部剔除一段与测试集时间重叠的样本,就像交叉验证里给数据“消毒”一样。对于时间粒度以天为单位的数据,剔除 7 天足够;以周为单位的,剔除 2 到 4 周。
5.2 坑二:类别特征编码方式选错
现象:模型训练不报错,但特征重要性排名里排名第一的竟然是某个区域的编码数字,业务上完全解释不通。
原因:把行政区名直接 LabelEncoder 成 0 到 49 的整数,模型会把这些数字当成有序数值——28 号区域和 29 号区域在特征空间里只差 1,模型会人为认为它们相似,但现实中这两个区域可能一个在莫斯科市中心、一个在远东边疆区。这是把无序类别当有序数值处理的经典错误。
解决:树模型用 LightGBM 的categorical_feature声明;如果用的库不支持原生类别特征,就用频次编码或 one-hot。唯一需要 LabelEncoder 的场景是目标变量 y,不是特征 X。
5.3 坑三:缺失值填充引入未来信息
现象:模型离线评估分数很高,上线后预测结果经常出现“这个月预测和上个月完全一样”的诡异情况。
原因:用全局均值或中位数填充缺失值,比如用全量数据的平均案发时长填充缺失值,这个均值本身包含了未来信息。测试集那段时期的真实均值可能已经变化,但模型仍然拿着历史全局均值做特征,导致输出对时间变化不敏感。
解决:时间序列数据的缺失值必须用时间窗口填充。用前向填充ffill()或用过去 7 天滚动均值填充,严禁使用全局统计量。实在要用中位数填充,也必须只用训练集的中位数,并且保存这个值用于推理阶段。
5.4 坑四:589 KB 数据硬套深度学习
现象:照着某个教程把犯罪类型文本送到 LSTM 或 Transformer 里,训练 50 轮,训练集 loss 降到 0.01,测试集 loss 在 2.5 附近震荡,模型输出的预测结果干脆全是同一个类别。
原因:几千条样本对深度学习来说连“热身”都不够。词嵌入需要海量文本预训练,序列模型需要大量样本捕捉时间依赖,强行在小数据上训练,模型只会把训练集背下来,测试时彻底摆烂。
解决:先跑通一个逻辑回归或 LightGBM 基线,拿到一个不算丢人的分数之后,再考虑要不要升级模型。从小数据集做项目,最重要的一课就是先快速跑通一个完整 pipeline,再回来优化模型,而不是一上来就追求 SOTA。如果深度学习是你的执念,有一个折中方案:用预训练的文本 embedding 模型把案情描述转成向量,然后作为特征喂给 LightGBM,至少文本信息被利用了,风险也可控。
5.5 坑五:zip 包里的代码跑不通是编码问题
现象:解压后运行源代码脚本,直接报SyntaxError: Non-ASCII character或者UnicodeDecodeError。
原因:俄语地区的开发者经常用 cp1251 编码写 Python 源文件,Windows 下默认 GBK 读取,中文环境更乱。有些文件甚至没加# -*- coding: utf-8 -*-声明。
解决:运行前先检查文件编码。用 VS Code 打开文件看右下角编码格式,或者用下面的命令快速检测:
file crime_data/scripts/*.py如果是ISO-8859或Unknown,先用 text editor 另存为 UTF-8。处理 CSV 数据文件时,read_csv加encoding="cp1251"或encoding="latin-1"轮番尝试,直到列名不再是乱码。这个坑没有技术难度,但最容易让人在项目一开始就放弃。
6. 模型评估与可解释性:让预测结果能落地到业务决策
6.1 第三个源代码脚本:预测与概率输出
模型训练完,别只输出一个predict结果就完事。对于犯罪预测,业务方需要的不是“这个区域明天会犯罪”(没人会信),而是“这个区域明天发生盗窃的概率是 0.73”。概率输出为决策提供依据——阈值 0.5 还是 0.7,警力调度方案完全不同。
import joblib # 保存模型,方便推理时加载 joblib.dump(model, "crime_model.pkl") # 推理:输出每个类别的概率而不是硬标签 probabilities = model.predict_proba(X_new) class_names = model.classes_ result_df = X_new.copy() for i, cls in enumerate(class_names): result_df[f"prob_{cls}"] = probabilities[:, i] # 按最高概率排序,取每个区域的前三类风险 top3 = result_df[["district", "prob_theft", "prob_robbery", "prob_murder"]] top3["predicted_risk"] = top3[[f"prob_{c}" for c in class_names]].idxmax(axis=1) print(top3.head(10))这段推理代码有三个关键点:一是用joblib.dump保存模型时把训练阶段的类别顺序也存进去,推理时直接取model.classes_对齐,否则你压根不知道概率 0.73 对应的是“盗窃”还是“抢劫”;二是输出概率后可以动态调整判定阈值——犯罪预测的任务里,把阈值调低意味着“宁可错报不可漏报”,适合高风险区域;调高则相反;三是把预测结果和业务决策映射,输出的不是“某类犯罪”,而是“哪三类风险最高”。
6.2 特征重要性与 SHAP 值:向业务方讲清楚“模型凭什么这么预测”
LightGBM 的feature_importances_是最粗糙的全局重要性,能告诉你是时间特征更重要还是地点特征更重要,但回答不了“为什么把某个区域判为高风险”。这时候用 SHAP 做局部解释:
import shap explainer = shap.TreeExplainer(model) shap_values = explainer.shap_values(X_test) # 看一下单个样本的解释 shap.force_plot(explainer.expected_value, shap_values[0], X_test.iloc[0])SHAP 图的阅读方法:base value 是所有样本的平均预测值,红色部分是推动预测值上升的因素(比如district_freq=128这个特征表明该区域历史发案频率极高),蓝色部分是抑制因素。对于犯罪预测这个场景,SHAP 不只是给你自己看的——当你需要让业务方相信模型不是“黑匣子”时,一张清晰的 SHAP force plot 胜过十页报告。
6.3 一个必须做的验证方法:时间回测
最后分享一个我习惯的做法,也是这套预测流程的收尾验证。训练完模型,不要只看一次测试集的结果,做一次滚动时间回测:把数据按时间分成 5 段,第一段训练、第二段验证,然后第一段加第二段训练、第三段验证,依次滚动下去。这个回测模拟了模型在真实部署时的情况——它面对的永远是“过去没见过的时间点”。
from sklearn.model_selection import TimeSeriesSplit tscv = TimeSeriesSplit(n_splits=5) scores = [] for train_idx, val_idx in tscv.split(X): X_train_cv, X_val_cv = X.iloc[train_idx], X.iloc[val_idx] y_train_cv, y_val_cv = y.iloc[train_idx], y.iloc[val_idx] model_cv = lgb.LGBMClassifier(**model.get_params()) model_cv.fit(X_train_cv, y_train_cv, categorical_feature=categorical_features) scores.append(model_cv.score(X_val_cv, y_val_cv)) print("各折准确率:", scores) print("平均准确率:", np.mean(scores))如果滚动回测的分数比单次测试低了 10 个百分点以上,说明单次测试的结果大概率撞上了好运气,模型的真实泛化能力并没有那么强。这时候要回头检查特征是否泄漏、数据切分是否正确,而不是盲目调参。
这个项目最终能不能达到“可用”的水平,取决于你对目标定义得是否清楚——如果只是练手,跑通这套流程你学到的东西已经值回票价;如果要落地到真实业务,记住一个原则:模型输出的是风险概率,不是判决结果,最终决策必须由人来做。我做这类项目最大的教训是,一开始太执着于把 accuracy 刷高,后来才发现业务方需要的只是“哪些区域值得多派人巡逻”这种排序能力。排序对了,预测准不准反而不那么重要。希望帮到你。
本文还有配套的精品资源,点击获取