3、图数据构建:用户-设备交互图、交易网络图、时间序列图
做反欺诈,光有特征是不够的。你想想看,一个盗号团伙会用同一台设备登录几十个账号,或者一批账号在短时间内向同一个新注册商户转账。这种团伙行为在表格数据里很难直接捕捉,但一旦你把用户、设备、交易、时间都连成一张图,模式就一眼能看出来。GraphSAGE 的输入就是这些图,所以图建得怎么样,直接决定了模型的上限。
我自己踩过这个坑。刚开始做的时候,觉得图嘛,连几条边就好了。结果模型跑出来,很多正常用户都被误杀。后来一查,是图的边太多太密,诈骗行为和正常行为完全混在一起。所以这一章,我把自己总结的三种核心图构建方法讲清楚:用户-设备交互图、交易网络图、时间序列图。实际项目中,这三张图往往要叠加使用。
3.1 用户-设备交互图
这张图最容易理解。节点是用户 ID 和设备 ID(手机、电脑、浏览器指纹等),边表示“某个用户在某台设备上操作过”。逻辑很简单:如果一台设备关联了 5 个以上的不同用户,或者一个用户短期内换了十几台设备,这本身就是强风险信号。
构建要点:边可以带时间权重。比如登录时间离现在越近,边的权重越大。我个人习惯用 1 / (1 + 天数差) 做衰减,这样图里能保留最近活动的信息,远古设备关联就不会影响当前判断。
代码上,用 NetworkX 或者 DGL 都行。我一般先用 pandas 从日志表里拉出 (user_id, device_id, timestamp) 三元组,然后去重加权重:
import pandas as pd
import networkx as nx
# 假设 df 包含 user_id, device_id, login_time
df = pd.read_csv('user_device_log.csv')
df['date'] = pd.to_datetime(df['login_time']).dt.date
today = pd.Timestamp.now().date()
# 按用户-设备聚合,天数越近权重越大
edges = df.groupby(['user_id', 'device_id']).agg(
last_date=('date', 'max'),
count=('date', 'count')
).reset_index()
edges['weight'] = 1 / (1 + (today - edges['last_date']).dt.days)
edges['weight'] = edges['weight'] * edges['count'] # 登录次数也重要
G = nx.Graph()
G.add_weighted_edges_from(edges[['user_id', 'device_id', 'weight']].values)
这里有个坑。我曾经把“设备指纹”直接当节点,结果发现同一个物理设备在不同浏览器上会被识别成不同指纹,导致图变得异常稀疏。后来改成“设备指纹 + IP 段”合并成一个节点,边密度才正常。嗯,做图的节点粒度一定要跟业务对齐。
3.2 交易网络图
交易网络图是反欺诈里最常见的。节点可以是用户、商户、银行卡、甚至 IP 地址。边代表一次交易,方向从付款方指向收款方。属性可以带上金额、时间、交易类型。
为什么需要方向?因为诈骗往往是单向的:钱从大量受害账户流向少数恶意商户。有向图才能捕捉这种“汇聚”模式。在 GraphSAGE 里,如果用的是有向图,邻居采样就要区分入边和出边,否则信息会漏掉。
建议:交易网络图的边最好再加一个时间窗口。比如只保留最近 7 天的交易,超过 30 天的直接删掉。这样图的大小可控,也避免了陈年交易的噪声。我做过一次对比,不加时间窗口时模型 AUC 0.81,加了 7 天窗口后涨到 0.87。
构建示例(有向图,保留最近 7 天):
import networkx as nx
import datetime
df_txn = pd.read_csv('transactions.csv')
df_txn['txn_date'] = pd.to_datetime(df_txn['txn_date']).dt.date
cutoff = pd.Timestamp.now().date() - datetime.timedelta(days=7)
df_recent = df_txn[df_txn['txn_date'] >= cutoff]
G_txn = nx.DiGraph()
# 从付款方到收款方,权重为交易次数 + 总金额
edge_data = df_recent.groupby(['from_node', 'to_node']).agg(
count=('amount', 'count'),
total_amt=('amount', 'sum')
).reset_index()
for _, row in edge_data.iterrows():
G_txn.add_edge(row['from_node'], row['to_node'],
weight=row['count'] * 10 + row['total_amt'] / 1000)
注意:交易图的节点规模很容易爆炸。一个大型支付平台可能有上亿用户,全量加载到内存直接 OOM。解决办法是分层采样:先按风险分群,再分别建子图。或者用 Spark GraphX 做分布式构建,这里不展开。
3.3 时间序列图
前面两种图都是静态的(一段时间内的快照),但欺诈行为有很强的时间模式。比如一个账号突然在凌晨 3 点频繁交易,或者一个设备在 5 分钟内登录了 10 个不同账号。这些“突发性”用静态图很难表示,所以我们要建时间序列图。
时间序列图的做法其实是对传统时间序列的图化。把连续时间切分成窗口(比如每小时一个窗口),每个窗口内建一张静态子图,然后把这些子图按时间顺序串联起来。在 GraphSAGE 里,我们可以把时间窗口作为额外的节点维度,或者用循环图结构(比如 Temporal Graph Networks),但这里只说最实用的方法:滑动窗口图序列。
| 图类型 | 节点 | 边 | 时间含义 |
|---|---|---|---|
| 用户-设备交互图 | 用户, 设备 | 登录/操作行为 | 边权重随时间衰减 |
| 交易网络图 | 用户, 商户, 银行卡 | 交易流水 | 固定时间窗口(如近7天) |
| 时间序列图 | 用户+时间片 | 跨时间片的转移 | 窗口间的图结构变化 |
具体怎么建?我讲过的一个案例:用户在过去 24 小时内每 2 小时一个窗口,共 12 个窗口。每个窗口内以用户为节点,以“同设备登录”为边,建一个用户-设备交互子图。然后,同一个用户在相邻窗口的子图中出现,就加一条跨时间边的连接,表示该用户的活动轨迹。最后把这些子图合并成一个大图,每个节点带上时间戳标签。
# 伪代码:滑动窗口构建时间序列图
from datetime import timedelta
# 按时间排序的用户活动日志
df = df.sort_values('login_time')
window_size = timedelta(hours=2)
graphs = []
cur_start = df['login_time'].min()
while cur_start < df['login_time'].max():
cur_end = cur_start + window_size
df_window = df[(df['login_time'] >= cur_start) & (df['login_time'] < cur_end)]
# 在当前窗口建图(用户-设备)
G_window = build_user_device_graph(df_window)
graphs.append((cur_start, G_window))
cur_start += window_size
# 跨时间边:同一个用户在相邻窗口的节点之间加边
for i in range(len(graphs) - 1):
users_prev = set(graphs[i][1].nodes())
users_curr = set(graphs[i+1][1].nodes())
common = users_prev & users_curr
for u in common:
graphs[i][1].add_edge((u, graphs[i][0]), (u, graphs[i+1][0]))
说实话,时间序列图的构建成本最高,数据量通常大一个数量级。我的建议是:如果业务场景中确实存在“短时间内高频行为”的风险,再上时间序列图;否则用前两种图就够了,不然模型训练时间翻倍还容易过拟合。
3.4 三种图的融合思路
上面三种图不是互相替代的,而是互补的。实际应用里,经常把用户-设备图和交易网络图合并成一张异构图(Heterogeneous Graph),然后再加上时间序列的边属性。比如一个节点既可以是“用户”,又可以是“设备”,通过边类型区分。
下面这张流程图概括了从原始数据到 GraphSAGE 输入的完整链路:
最后想提醒一点:图不是越大越好。我见过有人把全量数据一股脑塞进去,结果图有上亿条边,GraphSAGE 训练一天都跑不完。后来我们只保留最近 30 天活跃用户和设备的子图,AUC 没降,训练时间从 10 小时降到了 40 分钟。所以建图时一定要带着业务视角去剪枝,这个“度”需要在实际项目中反复调。好,图构建这块就这么多,下一段该聊聊特征工程了。