ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

数据挖掘驱动案件串并:从特征工程到图分析排嫌疑人

2026/9/20 18:56:07 拓冰建站 浏览量
数据挖掘驱动案件串并:从特征工程到图分析排嫌疑人 简介《数据挖掘技术在案件串并和嫌疑人排查中的应用》是一份面向公安情报分析人员、侦查办案人员及数据挖掘学习者的技术文档型PDF围绕把海量犯罪数据转化为破案线索这一目标梳理从决策支持系统背景到具体算法落地的完整思路。内容先介绍关联规则分析、聚类分析、协同过滤、分类与回归四类方法在犯罪规律挖掘、高危人群库构建、相似案件合并侦查、嫌疑人轨迹推荐与预警中的用法再结合“金盾工程”业务背景展开案件特征库与违法犯罪人员特征库建设、串并案模型与排查模型设计、出租车与卡口手机基站等轨迹数据叠加比对等落地场景兼顾算法原理与公安实战需求。整包仅1个PDF文件约981KB便于随时查阅与打印研读。目前已有79人学习下载适合希望系统了解数据挖掘在刑侦情报领域应用路径的读者参考。1. 案件串并的难点不在相似度算法而在两个案子怎么变成可比向量辖区一年几千起案件靠人工翻卷宗串出关系的往往只有几十组。剩下的要么散在不同办案单位的记录里要么因为一份笔录写“撬门入室”、另一份写“破坏门锁后进入”被关键词匹配判成两回事。数据挖掘在案件串并和嫌疑人排查里干的是一件很朴素的事把每起案件压成一个向量让机器比谁和谁像再把人、案、物、地之间的关系铺成一张图从图上把需要优先核查的人排到前面。它不替代侦查判断只负责把几千起案件里的高相似对和高关联人捞出来让有限的人力盯住那几十条真正该看的线索。做数据治理、特征工程、图分析的工程师以及要评估这类系统好不好用的业务方是这套方法的主要读者。下面从数据建模一路讲到误报压降每一步都给可复现的代码和参数口径。2. 数据挖掘的特征底座把接报案记录拆成可计算的案件特征向量案件相似度算不准九成问题出在特征上而不是模型上。原始接报案记录里能直接用来比较的字段极少时间是一串时间戳地点是两个经纬度作案手法是一段自由文本涉案物品是口语化描述。数据挖掘要做的第一件事是定义特征字典把这几类字段统一成同一种数学形态集合型、向量型、数值型。集合型走 Jaccard向量型走余弦数值型走衰减核三种距离不能混着算否则量纲会互相污染。2.1 案件特征字典时间、空间、手法、物品四类字段怎么定先定字段再写代码。字段定义错了后面调多少参数都是白费。维度原始字段加工后特征类型加工口径时间occur_tshour、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: [fC{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, freq20000) cases[method_seg] cases[method_text].map(lambda s: .join(jieba.lcut(s))) vectorizer TfidfVectorizer( token_patternr(?u)\S, # 文本已切分好按空白取词即可 min_df1, # 样本少时先不丢词上线后调到 2 到 3 max_df0.6, # 压掉“进入”“物品”这类高频泛词 sublinear_tfTrue, # 长文本的词频做对数压缩 ) M_method vectorizer.fit_transform(cases[method_seg]) print(M_method.shape, vectorizer.get_feature_names_out()[:8])min_df控制的是“只在极少数案件里出现的词”这类词往往是写笔录时的个人用词习惯留着会让相似度虚高max_df0.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_km2.0): 空间衰减核tau 取作案半径的量级 return np.exp(-d_km / tau_km) def temporal_sim(dt_hours, tau_h72.0): 时间衰减核72 小时是常见串并口径里的近期尺度 return np.exp(-abs(dt_hours) / tau_h) # 手法文本向量之间的余弦相似度 S_method cosine_similarity(M_method)tau_km取 2 公里、tau_h取 72 小时是常见起点。空间衰减核的形状意味着 2 公里处相似度降到 0.375 公里处降到 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), indexids, columnsids))参数含义常用取值调大后的效果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(eps1 - THRESHOLD, min_samples2, metricprecomputed) 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)})eps1-THRESHOLD这个换算要记住因为 sklearn 收的是距离而不是相似度。min_samples2表示两案即可成串实务里常见做法是先按 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), columnste.columns_) freq apriori(onehot, min_support0.25, use_colnamesTrue, max_len3) rules association_rules(freq, metricconfidence, min_threshold0.6) rules rules.sort_values(lift, ascendingFalse) 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], ntypecase) G.add_node(fLOC_{r[entry_type]}, ntypeloc) G.add_node(fITEM_{r[target]}, ntypeitem) G.add_edge(r[case_id], fLOC_{r[entry_type]}, etypeoccur) G.add_edge(r[case_id], fITEM_{r[target]}, etypetarget) # 涉案人员节点同一个名字出现在多起案件里时边权累加 for p in r[persons]: G.add_node(p, ntypeperson) G.add_edge(p, r[case_id], etypeinvolved, weight1.0) # 社区发现较新版本 networkx 直接提供 louvain_communities comms nx.community.louvain_communities(G, seed42) 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, ascendingFalse))字段来源默认权重解释方式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(normalizeTrue).to_dict() idf_weight {k: 1.0 / (0.2 v) for k, v in freq_map.items()} # 多维度命中约束至少两个非时空维度同时命中才保留为候选 def keep_pair(s_detail, min_dims2): hit sum(1 for k, v in s_detail.items() if k not in (space, time) and v 0.5) return hit min_dimsmin_dims2意味着光靠地点近、时间近不够必须在手法或物品上也有实质重合。这一条能把候选对砍掉一半以上代价是会漏掉少量只靠时空吻合的串实务上这个取舍是划算的因为纯时空吻合的对子人工核起来最费时间、命中率也最低。5.2 留一法回溯与复核标签回流阈值调得对不对不能靠感觉。留一法是这个场景里最实用的验证手段把已知的串并结果拆开每次藏起一起案件看系统能不能把它重新召回同一个串里。def loo_recall(S, labeled_groups, threshold0.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]]))这个指标比精确率更能反映实际能力它回答的是“已知的关联系统能找回多少”。每天把复核人员的判断写成标签回流到特征库和阈值配置里是这套流程里唯一能持续降低误报的动作。最后一个技巧复核队列里排第一位的永远不该是分数最高的那条而应该是分数高、且解释字段最完整的那条。分数高但解释是空的记录交给谁看都是浪费时间这类记录应该先回到特征补全环节而不是继续往后流转。本文还有配套的精品资源点击获取