简介:《数据挖掘技术在案件串并和嫌疑人排查中的应用》是一份面向公安情报分析人员、侦查办案人员及数据挖掘学习者的技术文档型PDF,围绕把海量犯罪数据转化为破案线索这一目标,梳理从决策支持系统背景到具体算法落地的完整思路。内容先介绍关联规则分析、聚类分析、协同过滤、分类与回归四类方法在犯罪规律挖掘、高危人群库构建、相似案件合并侦查、嫌疑人轨迹推荐与预警中的用法,再结合“金盾工程”业务背景,展开案件特征库与违法犯罪人员特征库建设、串并案模型与排查模型设计、出租车与卡口手机基站等轨迹数据叠加比对等落地场景,兼顾算法原理与公安实战需求。整包仅1个PDF文件,约981KB,便于随时查阅与打印研读。目前已有79人学习下载,适合希望系统了解数据挖掘在刑侦情报领域应用路径的读者参考。
1. 案件串并的难点不在相似度算法,而在两个案子怎么变成可比向量
辖区一年几千起案件,靠人工翻卷宗串出关系的往往只有几十组。剩下的要么散在不同办案单位的记录里,要么因为一份笔录写“撬门入室”、另一份写“破坏门锁后进入”,被关键词匹配判成两回事。数据挖掘在案件串并和嫌疑人排查里干的是一件很朴素的事:把每起案件压成一个向量,让机器比谁和谁像,再把人、案、物、地之间的关系铺成一张图,从图上把需要优先核查的人排到前面。它不替代侦查判断,只负责把几千起案件里的高相似对和高关联人捞出来,让有限的人力盯住那几十条真正该看的线索。做数据治理、特征工程、图分析的工程师,以及要评估这类系统好不好用的业务方,是这套方法的主要读者。下面从数据建模一路讲到误报压降,每一步都给可复现的代码和参数口径。
2. 数据挖掘的特征底座:把接报案记录拆成可计算的案件特征向量
案件相似度算不准,九成问题出在特征上,而不是模型上。原始接报案记录里能直接用来比较的字段极少,时间是一串时间戳,地点是两个经纬度,作案手法是一段自由文本,涉案物品是口语化描述。数据挖掘要做的第一件事是定义特征字典,把这几类字段统一成同一种数学形态:集合型、向量型、数值型。集合型走 Jaccard,向量型走余弦,数值型走衰减核,三种距离不能混着算,否则量纲会互相污染。
2.1 案件特征字典:时间、空间、手法、物品四类字段怎么定
先定字段,再写代码。字段定义错了,后面调多少参数都是白费。
| 维度 | 原始字段 | 加工后特征 | 类型 | 加工口径 |
|---|---|---|---|---|
| 时间 | occur_ts | hour、dow、is_night | 数值/布尔 | 拆出昼夜节律,保留绝对时间用于衰减 |
| 空间 | lon、lat | 经纬度对、辖区编码、点位类型 | 数值+类别 | 保留原始坐标做距离,辖区做兜底聚合 |
| 手法 | method_text | 分词后 TF-IDF 向量、手法标签集合 | 向量+集合 | 文本走向量,标签走 Jaccard |
| 物品 | target | 物品类别集合 | 集合 | 映射到统一类目表,避免同物异名 |
| 侵入 | entry_type | 侵入方式标签 | 类别 | 归并同义表述,如“撬门”与“撬砸门锁” |
| 损失 | loss | 分箱后的损失档位 | 有序类别 | 分箱比原始金额更能抗异常值 |
有了这张表,下面三段代码就是把表落成矩阵的过程,全程只用 pandas 加 scikit-learn。
import pandas as pd import numpy as np # 模拟一批接报案记录,字段结构贴近常见业务系统导出,数据为构造示例 cases = pd.DataFrame({ "case_id": [f"C{i:04d}" for i in range(1, 9)], "occur_ts": pd.to_datetime(["2024-03-01 02:10", "2024-03-01 23:40", "2024-03-04 01:30", "2024-03-09 03:05", "2024-03-09 22:15", "2024-04-02 02:20", "2024-04-02 02:55", "2024-04-15 13:00"]), "lon": [116.41, 116.43, 116.40, 116.52, 116.51, 116.42, 116.41, 116.88], "lat": [39.91, 39.90, 39.92, 39.87, 39.86, 39.90, 39.91, 39.95], "entry_type": ["撬门", "技术开锁", "撬门", "翻窗", "翻窗", "撬门", "撬门", "尾随"], "target": ["电动自行车", "电动自行车", "电动自行车", "手机", "手机", "电动自行车", "电动自行车", "现金"], "method_text": ["撬门入室,破坏门锁后进入,翻动物品", "使用工具技术开锁,未破坏门体,直取目标", "撬砸门锁后进入室内,现场翻动明显", "翻越窗户进入,剪断防盗网", "由窗户翻入,剪断窗栅", "撬门进入,门锁被破坏", "破坏门锁后进入,作案后恢复原状", "尾随受害人至楼下,趁其不备夺取财物"], "loss": [3200, 2800, 3500, 4500, 5200, 3000, 3300, 1200], }) # 时间:拆成昼夜节律特征,绝对时间单独留给衰减核使用 cases["hour"] = cases["occur_ts"].dt.hour cases["dow"] = cases["occur_ts"].dt.dayofweek # 0 表示周一 cases["is_night"] = cases["hour"].isin([0, 1, 2, 3, 4, 5]).astype(int) # 损失:分箱,避免单笔特大金额主导相似度 cases["loss_bin"] = pd.cut(cases["loss"], bins=[0, 2000, 4000, 99999], labels=["低", "中", "高"]) print(cases[["case_id", "hour", "is_night", "loss_bin"]])逻辑上,这里把“什么时候作案”拆成了两套表示:is_night用于集合相似度,occur_ts原值留给后面的时间衰减核。参数上,夜间档边界写死在 0 到 5 点只是常见口径,实际要按辖区的作息分布做一次直方图,把案件量最低的那几个小时切出来。损失分箱的断点也不是固定的,看损失字段的分位数,用 5% 和 90% 分位数当断点比拍脑袋更靠谱。
2.2 中文作案手法文本的向量化:jieba 自定义词典加 TF-IDF
自由文本是案件数据里信息密度最高、也最难对齐的部分。直接用字面匹配会漏掉大量同义表述,直接上通用分词又会把“技术开锁”切成“技术”和“开锁”。自定义词典是绕不开的一步。
import jieba from sklearn.feature_extraction.text import TfidfVectorizer # 业务口径里的手法词必须先切对,否则关键词会被切碎后失去区分度 for w in ["撬门入室", "技术开锁", "翻窗", "撬砸门锁", "破坏门锁", "剪断防盗网", "尾随", "翻动物品", "恢复原状"]: jieba.add_word(w, freq=20000) cases["method_seg"] = cases["method_text"].map(lambda s: " ".join(jieba.lcut(s))) vectorizer = TfidfVectorizer( token_pattern=r"(?u)\S+", # 文本已切分好,按空白取词即可 min_df=1, # 样本少时先不丢词,上线后调到 2 到 3 max_df=0.6, # 压掉“进入”“物品”这类高频泛词 sublinear_tf=True, # 长文本的词频做对数压缩 ) M_method = vectorizer.fit_transform(cases["method_seg"]) print(M_method.shape, vectorizer.get_feature_names_out()[:8])min_df控制的是“只在极少数案件里出现的词”,这类词往往是写笔录时的个人用词习惯,留着会让相似度虚高;max_df=0.6反过来压掉出现在六成以上案件里的词,它们接近停用词。sublinear_tf解决的是笔录长短不一的问题,一份 300 字的笔录和一份 50 字的笔录不该因为字数差异拉出距离。这套词表要定期回流,把新增的手法表述补进自定义词典,否则半年后新出现的作案方式会切得七零八落。
2.3 类别编码、数值标准化与缺失值:别让空字段把相似度拉低
类别字段最容易踩的坑是缺失值。把缺失填成 0 或者“未知”这一类标签,在 Jaccard 计算时会变成两个案件“共享同一个未知特征”,凭空拉高相似度。正确做法是给缺失单独一个占位,并在计算相似度时把该维度整体跳过、重新归一化权重。
from sklearn.preprocessing import MinMaxScaler # 类别字段:缺失单独编码成 UNKNOWN,不与其他类别混用 for col in ["entry_type", "target", "loss_bin"]: cases[col] = cases[col].astype(object).where(cases[col].notna(), "UNKNOWN") # 数值字段:如果要做欧氏距离或输入树模型,先做极值压缩 scaler = MinMaxScaler() cases[["lon_n", "lat_n"]] = scaler.fit_transform(cases[["lon", "lat"]]) missing_ratio = cases[["entry_type", "target", "method_text"]].isna().mean() print(missing_ratio)判断一个维度能不能用,看缺失率而不是看它“看起来重要”。某维度缺失率超过三成,它在相似度里的权重就应该被自动降下来,或者干脆改用“已知才计算、未知不计入”的加权方案。这套处理看起来琐碎,但它决定了后面相似度矩阵是不是可信,比调模型参数优先级高得多。
3. 案件串并的相似度计算:分维度打分、加权融合与 DBSCAN 成串
特征矩阵建好之后,案件串并就变成一个打分加聚类的问题。这里的关键判断是:不同维度的相似度不能直接相加,必须先在每个维度内部归一化到 0 到 1,再按业务重要性加权。
3.1 分维度相似度:Jaccard、余弦与时空衰减核
三类特征对应三种相似度算法,写成一个函数组,后面复用起来方便。
import numpy as np from sklearn.metrics.pairwise import cosine_similarity def jaccard(a, b): """集合型特征:侵入方式、涉案物品、手法标签""" sa, sb = set(a), set(b) return len(sa & sb) / max(len(sa | sb), 1) def haversine_km(lon1, lat1, lon2, lat2): """球面距离,单位公里""" R = 6371.0 p1, p2 = np.radians(lat1), np.radians(lat2) dp, dl = p2 - p1, np.radians(lon2 - lon1) a = np.sin(dp / 2) ** 2 + np.cos(p1) * np.cos(p2) * np.sin(dl / 2) ** 2 return 2 * R * np.arcsin(np.sqrt(a)) def spatial_sim(d_km, tau_km=2.0): """空间衰减核:tau 取作案半径的量级""" return np.exp(-d_km / tau_km) def temporal_sim(dt_hours, tau_h=72.0): """时间衰减核:72 小时是常见串并口径里的近期尺度""" return np.exp(-abs(dt_hours) / tau_h) # 手法文本向量之间的余弦相似度 S_method = cosine_similarity(M_method)tau_km取 2 公里、tau_h取 72 小时,是常见起点。空间衰减核的形状意味着 2 公里处相似度降到 0.37,5 公里处降到 0.08,符合“同一人不太可能跨大半个城市连续作案”的经验。但城区和郊县的作案半径差异很大,郊区要把tau_km放宽到 5 到 8 公里,这个参数必须按辖区分别标定,不能全局一个值。
3.2 加权融合与串并判定:时间窗、空间半径与综合阈值三个必调参数
合成相似度之前,先用硬性窗口做一次粗筛:时间差超过 90 天、空间距离超过 20 公里的对子直接判为不相似。这一步能把 O(n²) 的候选对砍掉九成以上,几千起案件的规模下尤其重要。
W = {"method": 0.40, "space": 0.20, "time": 0.15, "entry": 0.15, "target": 0.10} ids = cases["case_id"].tolist() n = len(ids) S = np.zeros((n, n)) for i in range(n): for j in range(i + 1, n): d_km = haversine_km(cases.lon[i], cases.lat[i], cases.lon[j], cases.lat[j]) dt_h = abs((cases.occur_ts[i] - cases.occur_ts[j]).total_seconds()) / 3600 if d_km > 20 or dt_h > 24 * 90: # 硬窗口粗筛 continue s = (W["method"] * S_method[i, j] + W["space"] * spatial_sim(d_km) + W["time"] * temporal_sim(dt_h) + W["entry"] * jaccard([cases.entry_type[i]], [cases.entry_type[j]]) + W["target"] * jaccard([cases.target[i]], [cases.target[j]])) S[i, j] = S[j, i] = s np.fill_diagonal(S, 1.0) print(pd.DataFrame(S.round(2), index=ids, columns=ids))| 参数 | 含义 | 常用取值 | 调大后的效果 |
|---|---|---|---|
| tau_km | 空间衰减尺度 | 城区 2,郊区 5 到 8 | 远距离案件更容易被判为同一人 |
| tau_h | 时间衰减尺度 | 48 到 168 小时 | 长周期作案序列更容易成串 |
| method 权重 | 手法文本贡献 | 0.35 到 0.45 | 手法相似但不相关的对子变多,误报上升 |
| 硬窗口 | 时间/空间粗筛 | 90 天、20 公里 | 漏串风险上升,但计算量显著下降 |
| 综合阈值 | 成串判定线 | 0.60 到 0.70 | 阈值越低串越大,需要的人工复核量越高 |
权重的分配逻辑是手法权重最高,因为它最难伪装也最难改变;时空权重加起来不超过 0.35,因为同一人跨区作案、隔月作案都很常见。阈值不建议一步到位调到 0.5,先设在 0.65 左右跑一轮,看候选对数量落在什么量级,再决定往哪个方向微调。
3.3 DBSCAN 聚类成串与人工复核队列输出
有了相似度矩阵,成串就是把它转成距离矩阵之后做密度聚类。DBSCAN 比层次聚类更适合这个场景,因为它不需要预先指定串的数量,也能把孤案留在噪声里。
from sklearn.cluster import DBSCAN THRESHOLD = 0.65 D = np.clip(1 - S, 0, 1) # 相似度转距离 db = DBSCAN(eps=1 - THRESHOLD, min_samples=2, metric="precomputed") labels = db.fit_predict(D) result = pd.DataFrame({"case_id": ids, "cluster": labels}) for cid, grp in result[result.cluster >= 0].groupby("cluster"): print(f"串 {cid}: {list(grp.case_id)} 规模 {len(grp)}")eps=1-THRESHOLD这个换算要记住,因为 sklearn 收的是距离而不是相似度。min_samples=2表示两案即可成串,实务里常见做法是先按 2 起步,把结果分档:两案的串单独一个队列,三案以上且案均相似度高于 0.75 的放高优先队列。输出给复核人员的表里一定要带解释字段——每个对子贡献最大的那个维度是什么,是手法一致、地点邻近,还是时间连贯,人看的是理由不是分数。
4. 嫌疑人排查的关联规则与图挖掘:从共现到社区
案件串并解决的是“哪些案子是一伙人干的”,嫌疑人排查要解决的是“这伙人里都有谁”。这两步共享同一套特征底座,但用的算法不一样:前一步是相似度,后一步是关联规则和图结构。
4.1 Apriori 找高频共现:支持度、置信度、提升度怎么读
关联规则挖掘的输入是事务表,每起案件是一条事务,事务里的项是这起案件涉及的特征组合:手法、目标、时段、点位类型。跑 Apriori 之前先把连续量离散化,时间段切成凌晨、上午、下午、夜间四档,点位类型切成老旧小区、商住楼、沿街商铺、开放式院落。
import pandas as pd from mlxtend.preprocessing import TransactionEncoder from mlxtend.frequent_patterns import apriori, association_rules # 事务 = 一起案件的特征组合 tx = [ ["撬门", "电动自行车", "夜间", "老旧小区"], ["技术开锁", "电动自行车", "夜间", "老旧小区"], ["撬门", "电动自行车", "夜间", "老旧小区"], ["翻窗", "手机", "夜间", "商住楼"], ["翻窗", "手机", "夜间", "商住楼"], ["撬门", "电动自行车", "凌晨", "老旧小区"], ["撬门", "电动自行车", "凌晨", "开放式院落"], ["尾随", "现金", "下午", "沿街商铺"], ] te = TransactionEncoder() onehot = pd.DataFrame(te.fit(tx).transform(tx), columns=te.columns_) freq = apriori(onehot, min_support=0.25, use_colnames=True, max_len=3) rules = association_rules(freq, metric="confidence", min_threshold=0.6) rules = rules.sort_values("lift", ascending=False) print(rules[["antecedents", "consequents", "support", "confidence", "lift"]].head(8))三个指标要分清楚:support是这条组合在全部案件里出现的比例,用来过滤长尾噪声;confidence是前件出现时后件也出现的条件概率,用来看预测强度;lift大于 1 才说明两者不是各自独立发生的。排查里最有用的是 lift 高但 support 中等的规则,比如“老旧小区加夜间加电动自行车”这种组合,support 可能只有 0.15,但 lift 能到 3 以上,它描述的是一个具体的行为模式,比泛泛的高频组合有信息量得多。
4.2 NetworkX 建“人-案-物-地”异构图并做社区发现
关联规则只能看到两三个特征的共现,看不到整张关系网。把涉案人员、案件、涉案物品、案发点位都建成节点,用边表示出现关系,整批案件的关联结构就显出来了。
import networkx as nx G = nx.Graph() for _, r in cases.iterrows(): G.add_node(r["case_id"], ntype="case") G.add_node(f'LOC_{r["entry_type"]}', ntype="loc") G.add_node(f'ITEM_{r["target"]}', ntype="item") G.add_edge(r["case_id"], f'LOC_{r["entry_type"]}', etype="occur") G.add_edge(r["case_id"], f'ITEM_{r["target"]}', etype="target") # 涉案人员节点:同一个名字出现在多起案件里时,边权累加 for p in r["persons"]: G.add_node(p, ntype="person") G.add_edge(p, r["case_id"], etype="involved", weight=1.0) # 社区发现:较新版本 networkx 直接提供 louvain_communities comms = nx.community.louvain_communities(G, seed=42) for k, c in enumerate(comms): persons = [n for n in c if G.nodes[n].get("ntype") == "person"] print(f"社区 {k}: 人 {len(persons)},节点总数 {len(c)}")seed固定是为了结果可复现,社区发现本身有随机性,不固定种子每次跑出来的人分组会略有差异。判断一个社区有没有价值看两点:一是社区里有没有重复出现的人节点,同一个人出现在不同社区说明拆分过度;二是社区规模,超过三十个节点的社区基本没有核查价值,说明阈值太松。实际排查里,跨社区连边多的人比社区内部度数高的人更值得关注,前者是把两个团伙连起来的桥接节点。
4.3 嫌疑人排序打分与可解释字段输出
把相似度传导和图结构合并成一个排序分数,比单独看任何一个都准。分数本身不重要,重要的是每条分数后面跟着什么理由。
import numpy as np def suspect_score(case_sim_sum, hops, related_cnt, w=(0.5, 0.3, 0.2)): """case_sim_sum: 该人关联案件与目标串的平均相似度之和 hops: 该人到目标串在图上最短跳数 related_cnt: 历史关联案件数量""" return (w[0] * case_sim_sum + w[1] * (1.0 / (1.0 + hops)) + w[2] * np.log1p(related_cnt)) # 示例输出结构 rows = [ {"person": "P001", "case_sim_sum": 1.85, "hops": 1, "related_cnt": 4}, {"person": "P002", "case_sim_sum": 1.20, "hops": 2, "related_cnt": 2}, {"person": "P003", "case_sim_sum": 0.95, "hops": 1, "related_cnt": 7}, ] for r in rows: r["score"] = round(suspect_score(r["case_sim_sum"], r["hops"], r["related_cnt"]), 3) print(pd.DataFrame(rows).sort_values("score", ascending=False))| 字段 | 来源 | 默认权重 | 解释方式 |
|---|---|---|---|
| case_sim_sum | 相似度矩阵聚合 | 0.5 | 与该人关联案件最像的串有多像 |
| hops | 图最短路径 | 0.3 | 到目标案件隔了几层关系 |
| related_cnt | 历史关联统计 | 0.2 | 过往涉案记录的次数,做对数压缩 |
| 解释列 | 各维度贡献分解 | — | 明确写出“手法一致 + 同一小区”这类理由 |
权重不是固定的,如果数据里人员关联记录质量高,related_cnt的权重可以提到 0.3;如果人员关联数据稀疏、大量缺失,就把它降到 0.1,让图结构承担更多。每条结果必须能展开成“该人为什么排在这里”的三行解释,复核人员凭解释决定看不看,而不是凭一个分数。
5. 案件串并结果的误报压降与回溯验证
上线初期最常见的抱怨是“串出来的案子太多,看不过来”。这不是算法问题,是阈值和权重没做分层。压误报最有效的三个动作:按特征稀有度降权、要求多维度同时命中、按时段分组标定阈值。
5.1 IDF 降权与多维度命中约束
“入室”“被盗”这类词在笔录里到处都是,它们贡献的相似度应该接近于零。TF-IDF 本身自带 IDF 降权,但在手工加权的那部分维度,比如侵入方式和涉案物品,权重还是静态的。更细的做法是把每个特征值的全局频率算出来,频率越高权重越低。
freq_map = cases["entry_type"].value_counts(normalize=True).to_dict() idf_weight = {k: 1.0 / (0.2 + v) for k, v in freq_map.items()} # 多维度命中约束:至少两个非时空维度同时命中才保留为候选 def keep_pair(s_detail, min_dims=2): hit = sum(1 for k, v in s_detail.items() if k not in ("space", "time") and v >= 0.5) return hit >= min_dimsmin_dims=2意味着光靠地点近、时间近不够,必须在手法或物品上也有实质重合。这一条能把候选对砍掉一半以上,代价是会漏掉少量只靠时空吻合的串,实务上这个取舍是划算的,因为纯时空吻合的对子人工核起来最费时间、命中率也最低。
5.2 留一法回溯与复核标签回流
阈值调得对不对,不能靠感觉。留一法是这个场景里最实用的验证手段:把已知的串并结果拆开,每次藏起一起案件,看系统能不能把它重新召回同一个串里。
def loo_recall(S, labeled_groups, threshold=0.65): hit, total = 0, 0 for group in labeled_groups: for cid in group: others = [x for x in group if x != cid] total += 1 if any(S[cid][o] >= threshold for o in others): hit += 1 return hit / max(total, 1) # labeled_groups 为历史人工确认过的串,形如 [["C0001","C0003","C0006"], ...] print(loo_recall(S, [["C0001", "C0003", "C0006"]]))这个指标比精确率更能反映实际能力,它回答的是“已知的关联,系统能找回多少”。每天把复核人员的判断写成标签回流到特征库和阈值配置里,是这套流程里唯一能持续降低误报的动作。
最后一个技巧:复核队列里排第一位的永远不该是分数最高的那条,而应该是分数高、且解释字段最完整的那条。分数高但解释是空的记录,交给谁看都是浪费时间,这类记录应该先回到特征补全环节,而不是继续往后流转。
本文还有配套的精品资源,点击获取