简介:这是一套信贷风险控制课程作业的图数据库项目,面向金融科技方向学生、数据工程师及图数据库初学者,帮助理解如何用图数据库对信贷交易关系建模,并构建信贷风险分析与反欺诈系统。项目完整覆盖图结构存储、后端风险计算与前端可视化展示,支持信用评分、还款能力、关联关系等维度分析;资源包共13个文件,含4个Python脚本、2个Jupyter Notebook、CSV统计结果、模拟数据与说明文档,压缩包约7.94MB,目录清晰便于复用。目前已有34人学习下载。从内容预览看,资源提供从模拟数据构造、图结构导入到测试验证的完整链路,Notebook可快速生成或导入信贷交易图数据,Python脚本完成图查询、统计分析与风险评估,附带CSV与图片结果便于对照验证,整体是一套便于扩展调试的图数据库信贷风控课程设计参考,尤其适合需要交付完整项目或想快速上手图数据库应用的开发者。
1. 信贷数据放在关系表里,为什么反欺诈还是慢半拍
信贷反欺诈这个场景,最磨人的不是建模,而是把“关联”查出来。过去用关系型数据库存客户、流水、设备信息,要查一笔资金在两个账户之间怎么流转,得连四五张表做 JOIN,深度超过三层查询性能肉眼可见地往下掉,更别提单子量上来以后全表扫描的压力。图数据库换了个存储思路:把人、卡、设备、交易当成节点,把钱怎么流、谁和谁共享过设备当成边,查询天然沿着关系走,写起来顺手,跑起来也快。
这门课程作业要做的,正是用图数据库把信贷交易数据存成图结构,再在图上做风险分析和反欺诈识别,最后通过前端把网络关系可视化出来、后端把数据处理好接给界面。说白了,就是一个能演示、能答辩、也能真实跑起来的“风控图查询系统”。
它到底能解决什么?简单说三件事:第一,把孤立的信用卡账单、登录日志、转账记录串成一张关系网;第二,在关系网上直接跑风险规则,比如查异常环、查资金快进快出、查团体欺诈;第三,把结果以图的形式展示给风控人员看,靠人眼补上模型漏掉的部分。适合谁做?你要是正在选课程设计方向,或者工作中想试试图数据库在风控里的落地手感,这个项目是很好的切入点。接下来按实际开发顺序往下拆,从建模到查询再到可视化,最后把最常见的坑一次性说清。
2. 图数据建模:先想清楚点和边,再动手建节点索引
很多人拿到这个题目第一反应是装 Neo4j、导数据、跑查询,结果导完发现查询写得极其别扭,原因基本都出在建模上。图数据库里“怎么存”直接决定“怎么查”,建模错了后面全是返工。所以第一步不是写代码,是把业务对象画成一张图:哪些东西是节点,哪些关系是边,节点上放什么属性,边上要不要带权重。
2.1 信贷风控图里的标准节点与关系设计
信贷反欺诈最常见的图结构可以归纳为四类节点。客户节点代表借款人,主键用客户编号;账户节点代表银行卡或信贷账户,关联到客户;交易节点代表每一笔转账、消费或还款,金额和时间是核心属性;设备节点代表登录手机、IP地址、MAC地址等,用于识别“多人共用一台设备”这类风险。节点之间用关系连接:客户和账户之间是“持有”,账户之间是“转账”,客户和设备之间是“登录”,交易和账户之间是“发生”。
这里的关键设计决策是:交易到底作为节点还是作为关系。我的建议是作为节点。原因是交易有独立的属性集合,比如金额、时间、渠道、对手方,如果作为边存在,边属性多起来之后查询和聚合都别扭。把交易当节点还有一个好处:可以同时挂两个账户关系,一笔交易从A账户到B账户,自然构成“A -[转出]-> 交易 -[转入]-> B”的路径,后续查循环结构、查中间人,都靠这个中间点来承接。
关系属性也不能偷懒。转账关系上要存金额和交易时间,登录关系上要存设备指纹和登录时间,持有关系上可以存开户日期。这些属性在后期做时间窗口类规则时是必需的,比如查“同一设备在24小时内登录过超过3个客户”,没有登录时间属性写不出这个查询。建模时把属性想全,比事后补数据省力得多。
2.2 用 Cypher 完成节点与关系的批量创建脚本
建模确定后,数据导入我一般分两步走:先建约束和索引,再通过 CSV 批量导入。约束保证客户编号、账户号码这类唯一键不重复,索引让按属性过滤的查询不走全表扫描。这步不做,数据量过十万以后,一条查询跑几十秒是常态。
CREATE CONSTRAINT customer_id_unique IF NOT EXISTS FOR (c:Customer) REQUIRE c.customer_id IS UNIQUE; CREATE CONSTRAINT account_no_unique IF NOT EXISTS FOR (a:Account) REQUIRE a.account_no IS UNIQUE; CREATE CONSTRAINT device_id_unique IF NOT EXISTS FOR (d:Device) REQUIRE d.device_id IS UNIQUE; CREATE INDEX transaction_time_idx IF NOT EXISTS FOR (t:Transaction) ON (t.trans_time); CREATE INDEX transaction_amount_idx IF NOT EXISTS FOR (t:Transaction) ON (t.amount);这段脚本先给客户编号、账户号码、设备指纹建了唯一约束,数据重复时会直接报错而不是静默产生重复节点。交易创建了两个索引:时间索引服务“某时间段内所有交易”的过滤,金额索引服务“大额交易”的快速筛查。索引不是越多越好,但这两个字段在绝大多数风险查询里都会作为过滤条件出现,值得建。
节点导完再导关系。关系导入我用的是 Cypher 的 LOAD CSV 加定期提交方式,核心是避免一条事务塞入过多数据导致堆内存爆掉:
:auto USING PERIODIC COMMIT 5000 LOAD CSV WITH HEADERS FROM 'file:///transactions.csv' AS row MATCH (from:Account {account_no: row.from_account}) MATCH (to:Account {account_no: row.to_account}) MERGE (from)-[t:TRANSFER {trans_id: row.trans_id}]->(to) SET t.amount = toFloat(row.amount), t.trans_time = datetime(row.trans_time);这里有个细节要注意:如果同一对账户之间存在多笔转账,MERGE 必须要带上交易编号属性做唯一性区分,否则第二笔转账会直接覆盖第一笔。我见过不少人在这里踩坑,日志里导入条数正确,但图上每个账户对只剩一条转账边,所有图算法结果全部失真。
批量导入后一定要做校验。粒度上校验两个数:节点总数与 CSV 行数是否一致,边总数与交易明细行数是否一致;抽样上校验一个事实:随机挑一个账户,查它的转账出边数量,和源数据里这个账户的流水笔数对比。对不上就回头查 CSV 编码、账户号格式、重复行这三个常见问题,不要直接往下走。
3. 风险分析与反欺诈查询:从规则到社区发现的分层实现
图建好了,接下来才是重头戏:怎么用图查询识别欺诈。我习惯把实现分成三个层次。第一层是规则查询,直接写模式匹配来找特定的图结构;第二层是算法增强,在图结构上跑社区发现和中心性计算;第三层是把前两层的输出汇总成风险评分。三层都实现了,这个项目才能算完整,也才好在答辩时展示出深度。
3.1 快进快出、多头借贷与设备聚集三类规则查询写法
规则层是见效最快的部分。先看“快进快出”模式:一个账户在很短时间内收到一笔资金,又迅速转出给其他账户,典型特征是交易节点的时间差极小。用 Cypher 表达就是找到转入交易和转出交易之间时间差小于阈值的账户对:
MATCH (a:Account)-[:TRANSFER]->(t1:Transaction)<-[:TRANSFER]-(b:Account) MATCH (a:Account)-[:TRANSFER]->(t2:Transaction)<-[:TRANSFER]-(c:Account) WHERE t1.trans_time <= t2.trans_time AND duration.inSeconds(t1.trans_time, t2.trans_time).seconds < 3600 AND t1.amount > 10000 RETURN a.account_no, b.account_no, c.account_no, t1.amount AS in_amount, t2.amount AS out_amount, duration.inSeconds(t1.trans_time, t2.trans_time).seconds AS hold_seconds ORDER BY hold_seconds ASC LIMIT 100;逻辑是找到 A 收到 B 的钱之后一小时内转给了 C。现实业务里一小时可能太紧,有的团伙会把资金在中间账户里放一天再走,这个参数要结合样本调。LIMIT 100 是为了防止结果集过大把前端直接拖死,实际部署时应该把阈值参数化,做成风控接口的入参。
再看多头借贷:同一个客户在短时间内向多个放贷机构申请借款。在图里查的是客户节点通过账户节点连接到了多少不同的放贷机构节点:
MATCH (c:Customer)-[:HOLDS]->(a:Account)-[:TRANSFER]->(t:Transaction)<-[:TRANSFER]-(l:Lender) WHERE t.trans_time >= datetime() - duration({days: 30}) RETURN c.customer_id, count(DISTINCT l.lender_id) AS lender_count, sum(t.amount) AS total_borrowed HAVING lender_count >= 3 ORDER BY lender_count DESC;这个查询的核心是 count(DISTINCT l.lender_id),因为同一个客户可能在同一天向同一家机构借多笔,直接 count 会虚高。30 天窗口和 3 家机构这两个参数是经验值,不同信贷场景差异很大,做 demo 够用,生产环境要从历史坏样本里统计分布来定。
设备聚集规则更好理解:多个客户共用同一台设备登录,是团伙欺诈的强信号。图里直接按设备节点做聚合:
MATCH (d:Device)<-[:LOGIN_FROM]-(c:Customer) WITH d, count(DISTINCT c) AS customer_count, collect(c.customer_id) AS customers WHERE customer_count >= 3 RETURN d.device_id, customer_count, customers ORDER BY customer_count DESC;这里有两个点值得说明。第一,count 用的是 DISTINCT,一个客户登录同一设备一百次只算一次,否则一台个人手机就会因为长期使用被误判成团伙。第二,collect(c.customer_id) 把客户列表收集到数组里,方便后续下游系统直接读取做名单输出,不用再查一次数据库。
3.2 利用社区发现算法识别团体欺诈与中介节点
规则查询能抓到明显的模式,但抓不到那种“看不出明显规则、但结构上很可疑”的团体。比如 20 个客户互相转账,每个人的单笔金额都不大,时间间隔也没有规律,但整个子图与外部几乎没有交易往来,形成孤立小集团。这种结构靠规则很难枚举,但图算法里的社区发现一跑就出来了。
我一般用 Louvain 算法做社区划分。Neo4j GDS 库实现了一行调用:CALL gds.louvain.stream。跑之前要先建投影图,投影时把交易方向保留为双向,否则有向图会把回路切断:
CALL gds.graph.project('credit_risk_graph', ['Customer','Account','Transaction'], {TRANSFER: {orientation: 'UNDIRECTED'}}) YIELD graphName, nodeCount, relationshipCount; CALL gds.louvain.stream('credit_risk_graph') YIELD nodeId, communityId, modularity RETURN gds.util.asNode(nodeId).customer_id AS customer_id, communityId ORDER BY communityId, customer_id;Louvain 的输出是每个节点所属的社区编号。社区本身不是风险结论,它只是把图切成了若干密集连接的子图。拿到社区后,要再做一层业务判断:社区内节点数是否异常、社区与外部连接是否过少、社区内是否有已知黑名单成员。常用的补充指标是“社区内部交易占比”,超过 80% 的社区值得重点关注。
中介节点识别用 PageRank 或 Betweenness Centrality。PageRank 适合找资金枢纽:一个账户在转账网络里被大量账户指向,说明资金高度汇聚,可能是归集账户。Betweenness 适合找唯一通道:网络里两群节点之间只有这一个中间人,这种节点天然适合做洗钱通道。两者侧重不同,我一般两个都跑,结果取交集,交集部分做高优先级人工复核。
跑算法前有两点必须注意。第一是投影图的内存,数据量大时投影失败要检查 heap 配置;第二是算法结果要落到业务表里存储,因为图数据库在可视化界面上直接展示全部节点会导致前端卡死,后端的做法是把高风险的社区和节点导出成 JSON,交给前端按需渲染。
4. 后端 API 与前端可视化的工程落地
算法跑通了,还差最后一公里:把结果展示出来。课程作业也好,实际项目也好,可视化占了演示效果的八成。我见过不少算法做得不错、但界面一开就白屏的项目,问题多数出在后端接口返回的数据结构没跟前端约定好。这节把前后端联调的关键路径完整走一遍。
4.1 Flask 后端封装风险查询接口
后端我习惯用 Python Flask 加 neo4j 官方驱动。选择 Flask 的原因很简单:轻量、上手快、和 Python 生态里的算法库衔接顺畅。接口设计上,只暴露两个核心端点:一个是图数据查询接口,返回节点和边的 JSON;另一个是风险汇总接口,返回规则命中数和社区发现结果。
from flask import Flask, jsonify, request from neo4j import GraphDatabase app = Flask(__name__) driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "password")) def run_query(query, params=None): with driver.session() as session: result = session.run(query, params or {}) return [record.data() for record in result] @app.route("/api/graph/risk", methods=["POST"]) def get_risk_graph(): req = request.get_json() community_id = req.get("community_id") query = """ MATCH (c:Customer)-[:HOLDS]->(a:Account) WHERE c.community_id = $community_id OPTIONAL MATCH (a)-[t:TRANSFER]->(other:Account) RETURN c.customer_id AS id, a.account_no AS account_no, collect(DISTINCT {target: other.account_no, amount: t.amount, trans_time: t.trans_time}) AS transfers """ data = run_query(query, {"community_id": community_id}) return jsonify({"code": 0, "data": data}) if __name__ == "__main__": app.run(host="0.0.0.0", port=5000, debug=True)接口逻辑很直接:前端传入社区编号,后端查出该社区下的所有客户和账户,再把账户之间的转账关系嵌套进 transfers 数组。返回结构里,节点信息和关系信息是冗余的,这是有意为之——前端拿这个 JSON 可以直接驱动图渲染,不需要再做二次关联。
有几个参数必须注意。bolt 连接串里的端口要和 Neo4j 实际配置一致,默认是 7687,但很多人安装时改过端口,导致后端连不上。password 这里直接写在代码里是 demo 习惯,正式环境应该用环境变量注入。debug=True 只建议本地调试开,部署到服务器上必须关掉。
4.2 用 ECharts 关系图展示交易网络
前端可视化我推荐 ECharts 的 graph 系列,比很多专用图可视化库在入门门槛上低不少。它接受 nodes 和 links 两个数组,正好对应图数据库的输出结构。拿后端返回的 JSON 做一次映射,就足够渲染出能交互的信贷网络图:
const response = await fetch("/api/graph/risk", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ community_id: 12 }) }); const result = await response.json(); const nodes = []; const links = []; const nodeMap = new Map(); result.data.forEach(record => { if (!nodeMap.has(record.id)) { nodeMap.set(record.id, { id: record.id, name: record.account_no, category: "customer", symbolSize: 30 }); nodes.push(nodeMap.get(record.id)); } record.transfers.forEach(t => { if (!nodeMap.has(t.target)) { nodeMap.set(t.target, { id: t.target, name: t.target, category: "account", symbolSize: 20 }); nodes.push(nodeMap.get(t.target)); } links.push({ source: record.id, target: t.target, value: t.amount }); }); }); myChart.setOption({ tooltip: { formatter: function(params) { if (params.dataType === "edge") { return `转账金额:${params.value}`; } return `节点:${params.data.name}`; } }, series: [{ type: "graph", layout: "force", data: nodes, links: links, roam: true, force: { repulsion: 120, edgeLength: 80 }, label: { show: true, fontSize: 10 } }] });这段代码有几个地方值得展开。第一,nodeMap 用于去重,因为同一个节点可能出现在多笔交易里,不去重前端渲染会出重影。第二,tooltip 里区分了 dataType 为 edge 和 node 两种情况,用户鼠标悬停在边上能看到转账金额,悬停在点上能看到节点名称,这是演示时最常被问到的交互细节。第三,force 布局加 roam: true 让图可以拖拽缩放,节点多的时候不会挤成一团。
演示时如果发现图太密,可以在后端层面对结果做裁剪:只返回金额大于阈值或关系数大于阈值的节点子集。这个叫“骨干图提取”,真实业务里前端没法一次渲染几千个节点,必须靠后端先过滤一层。课程作业的数据量通常几千条边,ECharts 完全扛得住,但代码里保留这个裁剪逻辑,答辩时能加分。
5. 系统运行避坑:从数据导入到前后端联调的五个大坑
这部分是血泪经验汇总。我见过、也亲手修过不少这类图数据库风控系统的问题,下面按项目推进顺序把最容易翻车的五个点讲透。
5.1 金额字段被转成浮点导致对账不平
现象:导入交易数据后,按账户汇总转出金额,和源系统对账时差了几分钱。原因:CSV 里金额用浮点数解析,浮点精度误差在累加时被放大。解决:金额一律用整数分存储,导入时先转分再入库。
LOAD CSV WITH HEADERS FROM 'file:///transactions.csv' AS row MERGE (t:Transaction {trans_id: row.trans_id}) SET t.amount_cents = toInteger(row.amount * 100);这里有个隐蔽问题:toInteger(row.amount * 100) 对某些值会得到比期望少 1 分的结果,因为 19.99 在计算机里是 19.989999999999998,乘以 100 变成 1998.9999,转整数直接截断成 1998。正确做法是用 round 函数:toInteger(round(row.amount * 100))。数值计算里这种玄学问题,查起来最费时间。
5.2 APOC 插件没装导致时间函数报错
现象:查询里用 apoc.date.parse 或 apoc.text.levenshteinDistance 时报错 Function apoc.date.parse not found。原因:Neo4j 默认不启用 APOC 插件,即使把 jar 放进 plugins 目录也需要重启才生效。解决:确认 APOC 版本和 Neo4j 版本匹配,把 jar 放到 plugins 目录后重启服务,然后在系统库执行 CALL apoc.help("date") 验证。版本不匹配时不是报找不到插件,而是报类加载错误,这个更难排查。
5.3 后端查询内存超限导致 Neo4j 直接断连
现象:页面点击查询后,后端日志出现 OutOfMemoryError,Neo4j 浏览器也连不上。原因:Cypher 查询没加 LIMIT,或者 MATCH 路径没有锚点,导致全图遍历。解决:所有展示类查询强制加 LIMIT;条件类查询先定位到种子节点再向外扩展。我在所有接口里统一加了 LIMIT 100,宁可结果少一页,也绝不让查询拖垮数据库。
5.4 前端图渲染时节点 ID 冲突
现象:图渲染后边连到了错误的节点上,看起来两条边交叉但实际不该相连。原因:前端用节点名称做唯一标识,但不同客户可能同名,或者账户号在 CSV 里有前导空格。解决:后端返回结构里明确 id 和 name 两个字段,id 用数据库主键,name 只做显示,前端渲染一律以 id 为 key。
return jsonify({ "id": record["customer_id"], "name": record["customer_name"], "transfers": ... })改成这样之后,ID 冲突问题从源头消失,前端不再需要自己猜哪个字段是唯一的。这类问题在联调阶段出现时会让人怀疑是自己的渲染代码写错了,实际是后端没有把语义表达清楚。
5.5 导入 CSV 时中文字段名编码问题
现象:LOAD CSV 时字段名带中文,查询结果里中文键名变成乱码。原因:CSV 文件没有保存为 UTF-8 编码,Windows 下默认导出经常是 GBK。解决:用文本编辑器另存为 UTF-8 with BOM 再导入,或者在 Python 预处理脚本里做编码转换。顺带提醒,CSV 文件必须放在 Neo4j 的 import 目录下,否则会报文件找不到。
import pandas as pd df = pd.read_csv("transactions_raw.csv", encoding="gbk") df.to_csv("/path/to/neo4j/import/transactions.csv", index=False, encoding="utf-8-sig")utf-8-sig 带 BOM,Neo4j 读取时能正确识别编码,同时保留字段名的中文可读性。导出后顺手用wc -l对比一下行数,避免数据截断。
6. 让反欺诈结论可信:一套顺手且能写的模型校验方法
系统能跑起来只是第一步,答辩或汇报时最躲不开的问题是“你这套模型效果到底怎么样”。只展示几个抓到人的案例是不够的,还得有总体指标。图数据库项目的特点是没有传统意义上的“模型文件”,效果验证落在两个层面:规则层面看命中率和误报率,算法层面看社区划分的稳定性和业务可解释性。
我的做法是攒一份带标签的验证集,模拟项目 X 用的是过去 24 个月的信贷数据,其中欺诈月份按业务标注好。然后把规则和社区发现结果跑在这份数据上,计算三个指标:规则命中中已知欺诈的占比是召回率,命中中人工复核后确认欺诈的占比是精确率,F1 值取两者的调和平均。图算法结果的验证加一个“社区纯度”,即同一社区内已知欺诈客户占该社区总客户数的比例,这个指标能快速判断算法的分群效果。
具体验证步骤我一般这样操作:先从图里随机抽 3 个社区,把社区成员名单导出成 CSV,交给业务同学做盲审——不告诉他们社区编号,只让他们看完交易流水后独立判断哪些人有欺诈嫌疑,然后和算法结果对照。第一次对照往往会发现一部分被算法漏掉的节点,这些节点的共同特征要回填到规则里,形成闭环。这一步非常关键:规则不是静态的,要用算法发现的结构反哺规则迭代。
参数调优方面,我最常调的是快进快出规则里的 3600 秒时间窗口。方法很简单:把窗口从 30 分钟开始,按 30 分钟步长递增到 6 小时,分别计算召回率和精确率,画一条曲线。曲线交叉点附近就是最优窗口。有一次我发现窗口大于 3 小时后精确率急剧下降,原因是正常企业客户的货款回收也在这个时间段内完成了资金周转,被误判成快进快出。这就是为什么不能用固定经验值——不同客群的资金习惯差异太大。
页面布局上有个实用建议:如果时间允许,把社区发现结果和规则命中结果放在两个独立的视图,分别用不同颜色高亮。规则命中用红色标节点,社区异常用蓝色圈边界,一眼能看出两套逻辑的重叠区域。这部分我吃过亏:第一次展示把两类结果混在一起,业务同学看着密密麻麻的图标根本分不清哪些是“确定的欺诈”哪些只是“待复核”,现场效果不佳。
整套走下来,这个项目帮我养成了一个习惯:任何风控规则上线之前,先跑一遍历史数据做盲测,把误报率和召回率打出来贴在工位上。图数据库不像传统模型有现成的训练验证流程,但反欺诈系统的有效性恰恰最需要有说服力的验证。规则、算法、验证三者闭环,才能让一个课程作业级别的项目真正具备实用价值。希望这些踩坑记录和实现思路能帮到你,做的时候少走几段弯路。
本文还有配套的精品资源,点击获取