简介:信贷风险控制课程作业的图数据库项目,是一套基于图数据库技术构建的信贷风险分析与反欺诈系统实现方案,适合金融科技、数据科学方向的在校学生,以及需要快速搭建信贷风控演示系统的开发者。项目围绕信贷交易数据的图结构存储与可视化分析展开,涵盖信用评分、还款能力分析、关联关系分析等维度,能够帮助使用者直观理解图数据库在反欺诈和风险识别中的应用路径。压缩包共13个文件,包含4个Python脚本、2个Jupyter Notebook、CSV模拟数据、txt数据及说明文档、md与docx使用文档、rar附赠资料和示例截图等,总体积约7.94MB,既有可直接运行的后端处理代码,也有图形化展示与测试流程。目前已有32人浏览学习,适合作为课程作业参考、毕业设计基础或图数据库入门实践项目,可直接基于现有代码进行二次开发,快速验证信贷交易中的异常行为与风险集中点。
1. 为什么信贷风控要用图数据库:把风险藏在关系链里
图数据库处理信贷风险,最大的优势不是“查询快”,而是能把钱、人、手机号、设备之间的关系直接变成图上的边,沿着关系链一层层往外走。这个课程作业项目把借款人、贷款申请、交易流水、手机号和设备建模成图结构,后端用 Cypher 查询做多头借贷、资金闭环和团伙识别,前端把查询结果渲染成可拖拽的力导向图。对正在准备数据库课设的人来说,它是一套能直接跑通的前后端闭环;对已经在做风控或反欺诈的工程师,它更像是拆开的样例,告诉你图建模怎么落地、哪些查询模式真正有用。你不需要懂很深的图算法,把节点和关系建对,反欺诈线索就会自己浮出来。
2. 数据建模与入库:把贷款流水变成一张可以查询的关系网
2.1 节点的划分:什么人、钱、手机号,该拆成几张表
关系型数据库里设计表结构,第一反应是“放几个字段”。图数据库设计的第一步完全不同:你要先确定哪些东西是实体,哪些实体之间存在明确的关系。信贷场景里最常见的四类实体是用户、贷款申请、交易流水和设备信息。实体定了,关系跟着定:用户申请贷款,用户拥有手机号,用户绑定设备,用户向另一个用户转账。每一类关系都可以在边上挂属性,比如转账金额、申请时间,不需要额外做中间表。
我一般建议把“转账”直接建模成用户到用户的关系边,而不是一个独立的 Transaction 节点。原因有两个:一是风控查询大多关心“谁和谁有资金往来”,把转账建在边上,查询时少跳一层;二是课程作业的数据量基本在几万条以内,不会遇到性能瓶颈。如果以后要接入银行核心系统,再把交易做成独立节点,用 FROM 和 TO 两条关系指向双方,也不难改造。
下面是这个项目的节点设计思路,可以直接对照 CSV 字段看:
| 实体 | 关键属性 | 对应 CSV | 用途 |
|---|---|---|---|
| User | id, name, credit_score | user.csv | 借款人主体 |
| Loan | id, amount, status, date | loan.csv | 贷款申请记录 |
| Phone | number | phone.csv | 手机号,关联多方用户 |
| Device | device_id, model | device.csv | 设备指纹,识别一机多贷 |
| 关系 | 属性 | 起点 | 终点 |
| APPLIED | amount, date | User | Loan |
| HAS | since | User | Phone / Device |
| TRANSFER_TO | amount, ts | User | User |
这样设计之后,查询“哪些用户共用过同一台设备”只需要从 User 走到 Device 再走回 User,一条路径就出来了。
2.2 CSV 批量导入:用 LOAD CSV 把文件塞进 Neo4j
数据文件的放在 Neo4j 的 import 目录下,然后用 LOAD CSV 语句导入。下面这段是导入用户实体的最简写法:
LOAD CSV WITH HEADERS FROM 'file:///user.csv' AS row MERGE (u:User {id: row.user_id}) SET u.name = row.user_name, u.credit_score = toInteger(row.credit_score)这段 Cypher 的逻辑是按 CSV 里的user_id去图里找 User 节点,找到就更新属性,找不到就创建。这里不能用CREATE,同一份 CSV 重复导入时 CREATE 会生成重复节点,数据变得没法看。toInteger()是把字符串转成整数,CSV 里所有字段读进来默认是字符串,不转类型的话后面做数值聚合会出问题。
导入关系时需要注意先保证起点和终点都存在:
LOAD CSV WITH HEADERS FROM 'file:///transaction.csv' AS row MATCH (from:User {id: row.from_id}) MATCH (to:User {id: row.to_id}) MERGE (from)-[t:TRANSFER_TO {txId: row.tx_id}]->(to) SET t.amount = toFloat(row.amount), t.ts = datetime(row.ts)MATCH用来定位已经存在的用户节点,找不到会直接跳过这一行,不会自动创建节点。对于清洗过的数据这没问题,但如果 CSV 里有脏的from_id,转账关系就会悄悄丢失。课程作业里可以先跑一个匹配检查,用RETURN count(*) WHERE n IS NULL这类统计看丢了多少行。
2.3 索引与约束:不加唯一约束,图谱跑几天就废了
导入数据之前一定要把唯一约束建好。Neo4j 4.x 和 5.x 的语法略有不同,4.x 用ASSERT,5.x 用REQUIRE。我自己的习惯是在 5.x 下建索引:
CREATE CONSTRAINT user_id_unique IF NOT EXISTS FOR (u:User) REQUIRE u.id IS UNIQUE; CREATE CONSTRAINT loan_id_unique IF NOT EXISTS FOR (l:Loan) REQUIRE l.id IS UNIQUE; CREATE INDEX phone_number_index IF NOT EXISTS FOR (p:Phone) ON (p.number);第一句约束保证两个 User 节点不可能有相同 id,第二句同理。第三句是普通索引,不给 Phone 建索引的话,后面按手机号精确匹配查询会全表扫描。导入大批量数据时这个差别体感非常明显:有索引是毫秒级,没索引是秒级甚至更慢。
注意一个常见坑:约束只能建在节点属性上,不能建在关系属性上。如果想让“同一对用户之间只保留一条转账关系”,靠约束做不到,只能在导入时用 MERGE 而不是 CREATE 来写关系。
3. 反欺诈查询与风险评分:让风险规则跑在图上
3.1 一机多贷与一卡多人:两条 Cypher 找出团伙苗头
反欺诈里最经典的场景就是“一机多贷”:同一台设备被多个用户用来申请贷款。这个问题在关系型数据库里要 self join 好几次,在图数据库里只需要从 Device 节点走两步:
MATCH (u:User)-[:HAS]->(d:Device) WITH d, count(u) AS userCount WHERE userCount > 1 MATCH (u:User)-[:HAS]->(d) OPTIONAL MATCH (u)-[:APPLIED]->(l:Loan) RETURN d.device_id, collect(DISTINCT u.id) AS users, count(DISTINCT l.id) AS loanCount ORDER BY loanCount DESC LIMIT 20;第一段WITH先统计每台设备关联了多少用户,过滤出关联用户数大于 1 的设备,然后第二段再把这些设备和用户捞出来,最后用OPTIONAL MATCH匹配他们申请的贷款。OPTIONAL MATCH的意思是用户没有贷款申请也不丢行。这条查询返回结果后,前端可以直接把它们渲染成红色标记。
同样原理,手机号也可以用同一套路做“一卡多人”识别。把Device换成Phone,整条查询的逻辑完全不用动。实际项目里手机号的关联度比设备更高,因为设备可能换,手机号往往长期持有。
3.2 资金闭环:环状转账怎么用一条路径查出来
反欺诈里另一个高价值线索是资金闭环,比如 A 转给 B,B 转给 C,C 又转回 A,钱在团伙内部转了一圈。这种环形结构在金融风控术语里叫“构造性交易”,常见于刷单、洗钱和贷款材料造假。图数据库查环是天然的强项:
MATCH p = (a:User)-[:TRANSFER_TO]->(b:User)-[:TRANSFER_TO]->(c:User)-[:TRANSFER_TO]->(a:User) WHERE NOT a = b AND NOT b = c AND NOT a = c WITH p, [n IN nodes(p) | n.id] AS loopUsers, [r IN relationships(p) | r.amount] AS amounts RETURN loopUsers, amounts, reduce(s = 0.0, x IN amounts | s + x) AS loopTotal LIMIT 20;WHERE NOT a = b AND NOT b = c AND NOT a = c排除自环,即一个人转给自己不算闭环。reduce函数把三条边的金额累加起来,得到整个环路的资金总量,方便前端按金额排序。注意路径查询要控制循环长度,三环以内的查询在有索引的情况下毫秒级返回,六环以上数据量大时会变慢,建议先查三环再逐步扩展。
3.3 风险评分:把图特征写回节点属性
反欺诈查询的输出只是一堆“可能有问题”的名单,评分模块要做的就是把图特征量化。我在这类项目里常用的做法是给每个用户计算三个指标:出度(给多少人转过钱)、总转出金额、关联设备数量,然后加权成一个 0 到 100 的风险分。这个过程分成两步,先把统计结果写回节点:
MATCH (u:User)-[t:TRANSFER_TO]->(neighbor:User) WITH u, count(DISTINCT neighbor) AS outDegree, sum(t.amount) AS totalOut SET u.risk_degree = outDegree, u.risk_total_out = totalOut;count(DISTINCT neighbor)防住同一个人给另一个用户转很多次导致的虚高。SET会直接在原节点上增加属性,之后所有查询都能直接引用,不需要每次现算。
第二步是统一用 Cypher 做分数合成,方便后端 API 直接返回:
MATCH (u:User) OPTIONAL MATCH (u)-[:HAS]->(d:Device) WITH u, count(DISTINCT d) AS deviceCount SET u.risk_score = CASE WHEN u.risk_degree >= 10 THEN 80 WHEN u.risk_degree >= 3 THEN 50 ELSE 20 END + deviceCount * 10 RETURN u.id, u.risk_score ORDER BY u.risk_score DESC LIMIT 30;这里的权重是我拍脑袋定的,课程作业完全够用。真实业务里权重必须用历史坏样本去拟合,不能直接抄别人的公式。把risk_score作为独立属性放在节点上,还有一个额外好处:Neo4j 可以直接对这个属性建索引,前端查 Top N 风险用户时响应时间会快不少。
4. 前后端联动:图查询结果怎么落到页面上
4.1 后端接口设计与 Neo4j 驱动
后端我习惯用 Flask 加官方 Neo4j Python 驱动。Flask 轻量,课程作业里不需要上 Spring Boot 那么重的框架。驱动连接方式如下:
from flask import Flask, jsonify from neo4j import GraphDatabase app = Flask(__name__) driver = GraphDatabase.driver( "bolt://localhost:7687", auth=("neo4j", "your_password") ) def _fetch_risk_users(limit=30): query = """ MATCH (u:User) WHERE exists(u.risk_score) RETURN u.id AS id, u.risk_score AS score, u.risk_degree AS degree ORDER BY u.risk_score DESC LIMIT $limit """ with driver.session() as session: return session.run(query, limit=limit).data()代码里的exists(u.risk_score)过滤掉还没跑评分脚本的节点,防止前端页面出现一堆风险分为空的数据。session.run()的参数用$limit占位符传值,不要自己拼字符串,Cypher 注入虽然不如 SQL 那么普遍,但拼习惯了容易出事。
处理闭环查询的接口简单一点,可以直接返回路径上的用户 id 列表:
@app.route("/api/risk/rings", methods=["GET"]) def get_risk_rings(): query = """ MATCH p = (a:User)-[:TRANSFER_TO]->(b:User)-[:TRANSFER_TO]->(c:User)-[:TRANSFER_TO]->(a:User) WHERE NOT a = b AND NOT b = c AND NOT a = c RETURN [n IN nodes(p) | n.id] AS users, reduce(s = 0, x IN [r IN relationships(p) | r.amount] | s + x) AS total LIMIT 20 """ with driver.session() as session: data = session.run(query).data() return jsonify([{"users": row["users"], "total": row["total"]} for row in data])4.2 前端 ECharts 力导向图:节点和边的参数怎么调
前端可视化部分,ECharts 的 graph 类型是现成的工具,不需要自己写 Canvas。后端把节点和关系组装成 ECharts 的格式,前端一个setOption就能渲染:
async function loadGraph() { const resp = await fetch('http://localhost:5000/api/risk/top?limit=30'); const data = await resp.json(); const nodes = data.map(d => ({ id: d.id, name: d.id, symbolSize: Math.max(10, Math.min(60, d.score / 2)), itemStyle: d.score > 60 ? { color: '#c0392b' } : { color: '#2980b9' } })); const edges = data.flatMap(d => d.transfers.map(t => ({ source: d.id, target: t.to, value: t.amount }))); chart.setOption({ tooltip: {}, series: [{ type: 'graph', layout: 'force', roam: true, label: { show: true, fontSize: 10 }, data: nodes, links: edges, force: { repulsion: 300, edgeLength: [80, 200] }, lineStyle: { opacity: 0.6, width: 1 } }] }); }symbolSize我映射的是风险分,分值越高点越大。itemStyle里超过 60 分显示成红色,低于 60 蓝色,这样页面一眼就能看到风险集中点。repulsion是节点间的斥力,值越大节点分得越开;数据量在 30 个节点以内时 300 左右效果最好,超过 50 个节点建议调到 500 以上,否则点会挤成一团看不清。edgeLength控制边长度范围,用于调节聚团程度。
一个常见误区是试图把后端所有节点一次性推给前端。正确做法是后端只返回 Top N 节点以及它们之间的关系,页面只画这 N 个节点和它们之间的边。这样前端不用做任何裁剪,数据量大时也不容易卡。
4.3 前后端分离的跨域问题
课程作业如果用一个 HTML 文件直接打开,通过fetch请求http://localhost:5000/api/...,浏览器会拦截跨域请求。最简单的处理是在 Flask 后端加上跨域头:
from flask_cors import CORS CORS(app, resources={r"/api/*": {"origins": "*"}})只对/api/*开放跨域,不要给全局放开。如果项目里已经有 Nginx,也可以用反向代理把前端静态文件和后端接口放到同一个 origin 下,前端请求就变成同源了。两种方式二选一即可,两个都做也不冲突。
5. 避坑与排查:Neo4j 信贷项目最常见的五个问题
5.1 重复节点比 CSV 行数还多
现象:导入之后跑MATCH (u:User) RETURN count(*),数量比 CSV 的行数多出好几倍,图谱上一搜全是重名的人。
原因:导入脚本用了CREATE而不是MERGE,同一份 CSV 重复执行了多次,每个用户被建了好几遍。
解决:实体节点一律用MERGE,并且先建唯一约束。约束建好后,重复执行导入脚本也不会产生重复节点,只会更新已有节点的属性。如果已经产生了重复数据,用MATCH (u:User) WITH u.id AS id, collect(u) AS nodes WHERE size(nodes) > 1找出重复组,手动合并属性后删除多余节点。
5.2 LOAD CSV 导入几万行数据跑了半小时
现象:CSV 文件只有几万行,导入却迟迟跑不完,日志显示事务一直在重试。
原因:Neo4j 默认把 LOAD CSV 放在单个事务里执行,几万行的 CREATE 全部堆积在一个事务里,内存和锁都扛不住。
解决:加上USING PERIODIC COMMIT 500,让 Neo4j 每处理 500 行提交一次事务:
USING PERIODIC COMMIT 500 LOAD CSV WITH HEADERS FROM 'file:///transaction.csv' AS row MATCH (from:User {id: row.from_id}) MATCH (to:User {id: row.to_id}) CREATE (from)-[:TRANSFER_TO {amount: toFloat(row.amount)}]->(to)注意USING PERIODIC COMMIT只能配合LOAD CSV使用,且在大文件场景下收益明显。如果还是慢,检查是不是每行都在MATCH一个没有索引的节点属性,没有索引的话每一行都要全表扫描,这时候回到 2.3 节把索引补上。
5.3 资金闭环查询超时或返回空
现象:同样的环状转账查询,在示例数据上没问题,换到自己的数据集上要么超时,要么结果为空。
原因:一是数据量变大后路径长度没有限制,二是有不少自环转账被环查询语句包含进去,导致匹配范围爆炸。
解决:在查询里加路径长度限制,同时用WHERE排除自环:
MATCH p = (a:User)-[:TRANSFER_TO]->(b:User)-[:TRANSFER_TO]->(c:User)-[:TRANSFER_TO]->(a:User) WHERE a <> b AND b <> c AND a <> c AND length(p) = 3 RETURN p LIMIT 20;排除了自环之后,真正三人循环的数据会少很多,查询压力也随之下降。如果数据量超过十万条,考虑使用 APOC 的apoc.cypher.runTimeboxed给查询设置执行时限。
5.4 前端页面节点一多就白屏卡死
现象:后端一次性返回了几千个节点,浏览器标签页直接卡住,ECharts 渲染不出来。
原因:力导向布局是模拟物理过程,节点数量在几千这个量级时 CPU 计算量会指数级增长。ECharts 不是为这种规模设计的。
解决:后端接口改成只返回风险分 Top 50 的用户,前端再对边做一次过滤,只显示两端都在当前节点集内的边。如果确实需要展示全量数据,让后端把构图过程拆到多个接口,前端用分页或滚动加载的方式一段段渲染。
5.5 前端能打开页面,但接口请求全部 404
现象:部署阶段前端页面正常,点击数据查询按钮刷新的是浏览器首页,接口全部返回 404。
原因:前端工程访问的是/api/xxx,但 Nginx 配置里只把location /指向了前端静态文件目录,没有把/api转发给后端服务。
解决:在 Nginx 配置里增加一段代理规则:
location /api/ { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $host; }proxy_pass后面的地址替换成实际后端端口。改完配置记得执行nginx -t检查语法再 reload,不要直接重启。这一步是前后端分离项目的经典翻车点,我自己踩过一次之后,每次部署都会先单独 curl 一下后端接口,确认通了再管前端。
6. 把课程作业做成可演示的完整流程:验证方法与两个进阶技巧
资源下载之后不要急着全量导入,先把示例数据小批量跑通流程。我通常的做法是先构造一个 20 人的小数据集:10 个正常用户,10 个高风险用户,其中 5 个人共用两部手机,3 个人构成一个转账闭环。导入之后,按下面的顺序验证每个环节:
先验证数据层,用两条查询确认图结构完整。MATCH (u:User)-[:HAS]->(d:Device) RETURN d.device_id, count(u)能看到每台设备关联的用户数;MATCH p = (a:User)-[:TRANSFER_TO]->(b:User)-[:TRANSFER_TO]->(c:User)-[:TRANSFER_TO]->(a:User) RETURN p能直接看到环路是否生成。数据和预期一致,再启动 Flask 后端,浏览器访问接口确认 JSON 有返回。最后打开前端页面,检查风险点是否渲染成红色。
验证通过之后,还有两个进阶技巧值得加进去。第一个是使用 Neo4j GDS 库的社区发现算法,只用一行 Cypher 就能把所有关联用户分组:
CALL gds.louvain.stream({ nodeProjection: 'User', relationshipProjection: 'TRANSFER_TO', orientation: 'UNDIRECTED' }) YIELD nodeId, communityId RETURN gds.util.asNode(nodeId).id AS userId, communityId LIMIT 50;社区发现跑出来的结果可以直接给前端加一个“欺诈团伙编号”字段,同一社区的用户在页面上用相同颜色的边框标记。这个功能加完之后,演示效果会明显提升,因为评委一眼就能看到“哪些人是一伙的”。
第二个技巧是给风险分加一个解释性字段,而不是只返回分数。后端在组装响应时,把“共享设备数量”“关联金融总分”“所在闭环金额”作为独立字段返回,前端在点击节点时弹窗展示这些明细。这样整个系统看起来不像是查了个固定列表,而更像是基于图特征的规则引擎。你调试和写报告时也会更容易说清楚每个分数是怎么来的。
从那以后我每次跑这类图数据库项目,都会强制走一遍“小样本入库 → 闭环查询 → 风险评分 → 前端渲染”的四步验证流程,确认每一层都正常,再开始调参数和美化页面。做这类课程作业,最怕的不是技术复杂,而是数据、后端、前端任何一层悄悄出错却没人发现。希望帮到你。
本文还有配套的精品资源,点击获取