刷单这事儿,拆开了看就是人和地址的博弈。那些异常订单的收货地址,要么是“xx小区门口”这种模糊词,要么同一个地址反复出现但收件人换来换去。更离谱的是,一个手机号三分钟内下五单,IP从三个省份轮流跳。这些信号堆在一起,基本就是在脸上写“我在刷”。
光靠人工盯后台,一天顶多看几百条。几千几万条订单,眼睛会瞎。写套脚本自动抓特征,反而省事。
收货地址里的猫腻,不止是模糊
正常用户买箱牛奶都会填到门牌号,谁会在七天里用同一个地址收三件毫不相干的东西?刷单账号偏偏爱用“代收点”“菜鸟驿站”这类模糊地点,甚至直接写个废弃厂房的门牌。有个案例里同一个地址挂过四十多个账号,收的货从纸巾跨度到手机壳,所有订单都挤在凌晨两点到五点之间生成——那个时间段正常人早睡了。
IP登录时序更有意思。用Python把登录日志按时间轴拉出来,会发现刷单账号的IP经常在几个城市之间高频切换。比如一个账号早上八点在杭州登录,十点跳到郑州,下午三点又出现在广州。正常人的通勤都没这么拼。更典型的是,这些IP往往来自同一个C段,可能是一个团伙在用同一个代理池。
数据准备阶段,我用requests加BeautifulSoup爬过某电商平台的订单导出接口——当然,前提是你有商家后台权限。字段至少要抓这四个:订单号、收货地址、登录IP、下单时间。清洗时踩过一个坑:时间字段里有“2024-03-15 02:34:12”和“2024/3/15 2:34”两种格式,直接用pandas的to_datetime会报错,得先统一格式。另一个坑是收货地址里的全角半角混用,比如“(”和“(”,用str.replace逐个清掉。
去重不能只靠订单号。同一个刷单团伙会用不同的账号下同一件商品,但收货地址和IP会暴露他们。我一般按“地址归一化后的字符串 + IP前三位”做分组去重——比如“广东省深圳市南山区科技园”和“广东深圳南山科技园”归一化后算同一个地址,IP 192.168.1.x 的前三段一致也算同一段。这样能筛掉一批明显的重复数据,不至于把同一个团伙的订单拆成多个样本。
数据准备阶段最烦人的是缺失值。有些订单的IP字段直接空着,或者收货地址只写了“自提”。我的处理方式是:如果IP缺失但地址正常,用地址的经纬度反查一个大概的城市IP范围填进去;如果地址也缺失,直接丢弃——这种订单多半是刷手懒得填,留着反而污染模型。
别想着一次把所有数据都洗得干干净净。刷单数据的特点是脏得五花八门,你永远不知道下一批订单里会冒出什么格式的地址。保持一个迭代的心态,先跑通流程,再慢慢补清洗逻辑。
地址聚类:“广东省深圳市南山区”和“广东深圳南山科技园”得认成同一个地方
地址数据进了 DataFrame 以后,第一件事不是扔进模型,是先把那些“广东省深圳市南山区”和“广东深圳南山科技园”归一化成同一条。我踩过这个坑:直接用原始字符串做聚类,结果同一个菜鸟驿站被拆成了七八个簇,刷单团伙的地址聚合完全看不出来。
归一化用 jieba 分词配合行政区划词典。我从民政部官网扒了最新的省市区三级编码表,转成字典丢给 jieba.load_userdict()。分词后只保留“省、市、区、路、号、村、组、代收点、驿站”这些关键地理词缀,其他的“亲”“包邮”“好评”之类的垃圾直接滤掉。前后花了大概两个小时调词典,但后面聚类精度提升明显,值得。
def normalize_address(addr):
words = jieba.lcut(addr)
geo_tokens = [w for w in words if w in district_dict or len(w) > 1 and any(k in w for k in ['省','市','区','路','号','村','组'])]
return ''.join(geo_tokens)
经纬度转换用的是 geopy 的 Nominatim,但免费版有 QPS 限制。批量跑的时候我加了 time.sleep(1),五千条地址跑了快一个半小时。后来换成了本地部署的 GeoPandas 加离线 Shapefile 文件,速度才上去。
聚类算法我选了 DBSCAN 而不是 KMeans。原因很简单:你不知道刷单团伙的地址簇长什么形状。KMeans 要求你提前定 K 值,但一个团伙可能只用一个代收点,另一个团伙用三个不同的驿站,簇的数量和密度都不一样。DBSCAN 只需要调两个参数:eps(邻域半径)和 min_samples(核心点数)。
from sklearn.cluster import DBSCAN
# eps 设为 0.001(约 100 米),min_samples 设为 3
db = DBSCAN(eps=0.001, min_samples=3, metric='haversine')
clusters = db.fit_predict(radians_coords)
eps=0.001 换算成球面距离大概 100 米,这个值我试了好几轮。调太大了会把隔壁小区的正常用户也吞进来,调小了同一个菜鸟驿站门口的两个包裹都可能被拆成两个簇。最后拿一批已知的刷单样本做标定——它们的地址往往挤在某个 50 米半径范围内——才确定这个值。
聚类结果出来后,筛选异常簇的逻辑很直接:统计每个簇关联的 user_id 数量。正常地址簇里一般不超过 3 到 5 个用户,一家人或者合租室友。但如果一个簇里出现了 20 个以上的不同 user_id,而且下单时间集中在凌晨或整点,基本可以判定是刷单团伙的收割点。
我在实际数据里抓过一个极端案例:某个簇的经纬度指向一个废弃工厂的传达室,里面堆了 60 多个账号的包裹,收货人姓名全是“王伟”“李娜”“张强”这种高频名字。把这些账号的 IP 拉出来一看,果然来自同一个 C 段。
不过 DBSCAN 有个烦人的地方:它会把噪声点标为 -1,这些点里其实混着一些真正的独立刷手——一个人用一两个账号,地址写自家,但 IP 异常。所以我后来在聚类后又加了一层过滤:簇内 user_id 数量大于等于 5 且 IP 前三段重复率超过 60% 的,才打上“可疑团伙”标签。噪声点单独走另一条规则链,不放过也不误伤。
地址聚类跑通之后,下一步就该把 IP 登录的时序图拉出来看了。团伙的 IP 切换规律往往比地址还明显——同一台机器换着 IP 登录,时间间隔精确到秒,正常人不可能那么规律。
IP登录时序:正常人不会在 02:00:01 登录一个号,02:00:03 切另一个
地址聚类只能告诉你哪些人可能共用收货点,但没法解释他们是怎么登录的。IP 登录时序才是真正让羊毛党显形的东西。正常人不会在 02:00:01 登录一个账号,02:00:03 切另一个,02:00:05 再切第三个,然后每天重复这套节奏。这种规律性,比任何地址特征都难伪装。
假设你已经从订单表里拉出了每个用户的登录日志,字段大致是 user_id、login_ip、login_time。我习惯先把数据按用户分组,然后对每个用户生成一个 IP 切换序列和时间间隔数组。Python 里用 pandas 的 groupby 配合 shift 就能算:
import pandas as pd
# df 假设有 user_id, login_ip, login_time
df = df.sort_values(['user_id', 'login_time'])
df['prev_ip'] = df.groupby('user_id')['login_ip'].shift(1)
df['ip_changed'] = df['login_ip'] != df['prev_ip']
df['interval_sec'] = (
df.groupby('user_id')['login_time'].diff().dt.total_seconds()
)
有了这两列,每个用户的行为就能浓缩成几个关键指标:IP 切换频率(单位时间内的切换次数)、平均登录间隔、异地登录比例(IP 归属地跨省的比例)。我遇到过一批账号,它们的 IP 切换频率高达每小时 12 次,平均登录间隔只有 4 秒——正常人哪怕用密码管理器也没这么快。
光看阈值设规则容易漏。比如有的团伙故意把登录间隔拉长到 30 秒,混在正常用户的时间分布里。这时候我习惯扔给 做无监督异常检测。特征直接取上面算出来的那三个数值,再加一个 IP 去重数量除以总登录次数 的比例:
from sklearn.ensemble import IsolationForest
features = df_user_agg[['ip_switch_freq', 'avg_interval_sec',
'cross_province_ratio', 'ip_uniqueness_ratio']]
clf = IsolationForest(contamination=0.05, random_state=42)
df_user_agg['anomaly_label'] = clf.fit_predict(features)
contamination=0.05 表示假设 5% 的用户是异常,这个值可以根据历史刷单率调。我试过 contamination 设 0.1,结果把几个深夜加班写代码的程序员也给标红了——他们的登录时间也集中在凌晨,IP 也切得频繁。最后加了个后置过滤:异常用户还必须满足 IP 切换次数大于当日登录次数的 80%,才进入嫌疑名单。程序员再肝,也不太可能每登一次就换一台代理。
时序分析的结果往往是分层的:一部分账号被 Isolation Forest 直接标记,另一部分虽然落在正常区间,但和地址聚类出来的可疑团伙有强关联。比如同一个收货簇里的账号,登录时间戳精确到秒级对齐。这种跨特征的关联网络,才是识别团伙的最终拼图。
构建关联网络:用户、地址、IP 织成一张图,团伙自己浮出来
前面把地址和IP分别做了异常标记,但真正的团伙从来不是散兵游勇——他们共用收货点、共享代理池、登录时间像合唱团一样整齐。单看任何一维特征都容易被绕过去,比如团伙A用真实地址但IP乱跳,团伙B固定IP但地址虚构。只有把用户、收货地址、IP三样东西揉在一起,织成一张关系网,才能揪出那些共谋的节点。
最开始尝试用SQL做关联查询:找出哪些用户用了相同的收货地址段,再交叉匹配IP段的共现次数。跑了十几分钟,查出几百对关联,但根本看不出团伙边界——谁和谁是一伙的?全凭肉眼猜。后来换成 NetworkX 建图,才真正看清结构。
图里三种节点:user、、ip_block。边代表共现关系。一个用户和某个地址簇有边,意味着他至少下过一单寄到该簇内地址;用户和IP块有边,意味着他曾在那个IP段登录过。边的权重可以简单设为共现次数,也可以归一化到0到1之间,避免高频用户主导全图。
import networkx as nx
G = nx.Graph()
# 添加用户节点
for uid in df_user_agg.index:
G.add_node(f"user_{uid}", type="user")
# 添加地址簇节点,并连边
for cluster_id, users in address_cluster_users.items():
node = f"addr_{cluster_id}"
G.add_node(node, type="address")
for uid in users:
if G.has_edge(f"user_{uid}", node):
G[f"user_{uid}"][node]["weight"] += 1
else:
G.add_edge(f"user_{uid}", node, weight=1)
# 同理加IP块边(省略循环,逻辑一致)
建好图后,第一件事是看度分布。正常用户的度通常很小——一个人不会同时关联十几个地址簇和IP段。但团伙账号的度会明显偏高,因为他们共用多个地址和代理。用nx.degree_histogram(G)画个分布,尾巴拖得很长的部分就是嫌疑区。
图建完了,怎么切分团伙?试了 Girvan-Newman,跑得慢不说,小图还行,几千个节点就卡死了。换 Louvain 算法——community 库封装好了,一行调用:
import community as community_louvain
partition = community_louvain.best_partition(G, weight='weight')
返回的字典里每个节点都挂着一个社区ID。同一个社区的基本就是一伙人。拿一个真实数据集跑了一遍,17个社区浮出来,其中3个社区的节点数直接飙到30以上。点进去翻了翻——好家伙,收货地址全扎堆在几个城中村的菜鸟驿站,IP段呢,翻来覆去就那么几个低价境外代理。剩下的14个小社区就寒酸多了,有的才2个节点,典型夫妻店式的小打小闹。
但 Louvain 有个毛病:它会强制把所有节点都分进某个社区,哪怕这个节点只是孤立的正常用户。所以得加一道后处理:对社区内节点数量小于5的,直接标记为正常;对度小于2的用户节点,也从社区中剥离。这一步过滤掉了大量误报。
光有图还不够,社区是结构上的亲近,但时间上是否同步?我写了个辅助函数:取同一个社区内所有用户的登录时间戳列表,两两计算dt.minutes的差值分布。如果大量配对的时间差小于2秒,那几乎是脚本同步登录的铁证。
def check_time_alignment(partition, community_id, df_login):
users = [n for n, com in partition.items()
if com == community_id and n.startswith('user_')]
timestamps = df_login[df_login['user_id'].isin(users)]['login_time'].values
from itertools import combinations
close_pairs = 0
total_pairs = 0
for t1, t2 in combinations(timestamps, 2):
total_pairs += 1
if abs((t1 - t2).total_seconds()) < 2:
close_pairs += 1
return close_pairs / max(total_pairs, 1)
比例超过0.3的社区,基本可以确认是团伙。有一次我跑出来一个社区比例0.47。点开原始日志,那些账号的登录时间精确到毫秒对齐,像是同一个调度器分发的线程。收货地址虽然分散在三个省份,但全指向同一个虚拟号码的菜鸟驿站。这就是刷单团伙的典型画像:结构上松散(多地址多IP),时间上高度一致。
最终输出的嫌疑社区表,我会保留社区ID、成员数、时序对齐分数、以及社区内最频繁的地址簇和IP段。这些信息足够交给运营去人工核实。真羊毛党跑不掉,误抓的也能从时间对齐分数低这一点快速排除。
几个核心环节的代码手感
前文说完了社区划分和时间对齐的逻辑,但总得落地成能跑的代码。这里我挑三个最核心的环节,把关键片段贴出来。不是让你Ctrl+C就跑,是给你参考那个判断边界的手感。
地址聚类:DBSCAN 的 eps 怎么调?
收货地址经过分词、embedding之后,维度挺高。我试过KMeans,但刷单团伙的地址分布压根不是球形——他们可能在同一个城市撒开一片菜鸟驿站,KMeans硬分只能拆散团伙。改用DBSCAN,只认密度连通。
from sklearn.cluster import DBSCAN
import numpy as np
# addr_vectors: (n_samples, n_features),已归一化
# 关键参数 eps 和 min_samples
db = DBSCAN(eps=0.35, min_samples=3, metric='cosine')
clusters = db.fit_predict(addr_vectors)
# -1 为噪声,正常簇从 0 开始编号
# 建议先跑小批量数据,画出 eps 对噪声点比例的曲线,
# 选拐点处的 eps 值——我用的数据集拐点大约在 0.32~0.38 之间
min_samples 设 3 是因为团伙至少得有三个账号才值得追踪。eps 调大了会把不同街道的地址揉成一团,调小了单刷号全归为噪声。拿一批已知标签的数据跑一遍混淆矩阵,比瞎猜靠谱。
时序特征:rolling 窗口里藏了同步刷单
光有聚类不够,得看时间轴上的节奏。建一张用户登录间隔表,用分组的 rolling 窗口算每个用户连续两次登录的中位数间隔。脚本刷单的间隔往往稳定在几十毫秒,人工登录的间隔波动大得多。
import pandas as pd
# df_login 字段: user_id, login_time (datetime)
df_login = df_login.sort_values(['user_id', 'login_time'])
df_login['time_diff'] = df_login.groupby('user_id')['login_time'].diff().dt.total_seconds()
# 取每个用户倒数 10 次登录的中位数间隔
def median_last_n(g, n=10):
return g.tail(n)['time_diff'].median()
user_rhythm = df_login.groupby('user_id').apply(median_last_n, n=10)
# 中位数间隔小于 0.5 秒的用户标记为疑似脚本
suspect_users = user_rhythm[user_rhythm <= 0.5].index.tolist()
窗口大小 n 设 10 是因为刷单号可能混着真登录做掩护,取太少容易被单次异常带偏。n 设太大又会把早期手工登录的节奏算进去,掩盖后期的脚本行为。这个 10 是我拿手工标注的数据试出来的折中值。
网络可视化:NetworkX 画团伙关系
最后一步是把社区关系画出来。我不喜欢那种密密麻麻的力导向图,看得眼晕。我一般只画社区内节点数超过 5 的团伙,边只保留两节点之间有过地址簇重合或 IP 共享的强关联。
import networkx as nx
import matplotlib.pyplot as plt
G = nx.Graph()
# 假设 partition 是 {node: community_id},已过滤小社区
for node, com in partition.items():
G.add_node(node, community=com)
# 加边:两个用户共享过同一地址簇或同一 IP 网段
# edges_df 字段: user_a, user_b, weight
for _, row in edges_df.iterrows():
G.add_edge(row['user_a'], row['user_b'], weight=row['weight'])
# 按社区着色
colors = [G.nodes[n]['community'] for n in G.nodes]
nx.draw(G, node_color=colors, cmap=plt.cm.Set3,
node_size=20, edge_color='gray', width=0.3)
plt.show()
画完图你会看到屏幕上一堆彩色小点,有些孤零零地散着——那是正常用户,社区里就一两个连接。但团伙社区完全不一样,它们能拧成一团紧实的彩色块,内部边拉得密密麻麻,朝外几乎没什么连线。说真的,有回我盯着屏幕看了快五分钟,就为了确认那个团块里 12 个账号是不是同一个手机号注册的。挨个查了一遍,还真是。
代码能跑通只是第一步,调参数和画图时反复问自己“这个边界合理吗”,才是把模型从玩具变成工具的分水岭。
— 数据不会骗人,但特征工程得自己做。
评论