feat: 初始提交 - 科创企业特有风险的识别与管理 (数智风控系统)
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""知识图谱模块"""
|
||||
@@ -0,0 +1,169 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
供应链风险传染分析器
|
||||
基于 BFS 遍历实现风险穿透,计算传染距离和影响权重
|
||||
"""
|
||||
import logging
|
||||
from collections import deque
|
||||
from typing import Optional
|
||||
|
||||
import networkx as nx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def analyze_contagion(G: nx.DiGraph, target_company: str) -> dict:
|
||||
"""
|
||||
分析指定企业的供应链风险传染情况
|
||||
从上游(供应商)方向进行 BFS 穿透
|
||||
"""
|
||||
if target_company not in G:
|
||||
return {"error": f"企业 '{target_company}' 不在图谱中"}
|
||||
|
||||
result = {
|
||||
"company": target_company,
|
||||
"direct_risks": [], # 直接风险(一度关联)
|
||||
"indirect_risks": [], # 间接风险(二度及以上关联)
|
||||
"contagion_paths": [], # 风险传染路径
|
||||
"risk_score": 0, # 供应链风险总分
|
||||
"critical_nodes": [], # 关键断裂节点
|
||||
}
|
||||
|
||||
# BFS 从目标企业向上游遍历
|
||||
visited = set()
|
||||
queue = deque([(target_company, 0, [target_company])])
|
||||
visited.add(target_company)
|
||||
|
||||
while queue:
|
||||
current, depth, path = queue.popleft()
|
||||
|
||||
# 检查当前节点的上游(前驱节点)
|
||||
for predecessor in G.predecessors(current):
|
||||
if predecessor in visited:
|
||||
continue
|
||||
visited.add(predecessor)
|
||||
|
||||
edge_data = G.edges[predecessor, current]
|
||||
node_data = G.nodes.get(predecessor, {})
|
||||
new_path = [predecessor] + path
|
||||
|
||||
# 检查上游节点是否受制裁
|
||||
if node_data.get("is_sanctioned", False):
|
||||
risk_entry = {
|
||||
"entity": predecessor,
|
||||
"node_type": node_data.get("node_type", "未知"),
|
||||
"distance": depth + 1,
|
||||
"relation": edge_data.get("relation", ""),
|
||||
"is_critical": edge_data.get("critical", False),
|
||||
"status": edge_data.get("status", "正常"),
|
||||
"path": " → ".join(new_path),
|
||||
}
|
||||
|
||||
if depth == 0:
|
||||
result["direct_risks"].append(risk_entry)
|
||||
else:
|
||||
result["indirect_risks"].append(risk_entry)
|
||||
|
||||
result["contagion_paths"].append({
|
||||
"path": new_path,
|
||||
"path_str": " → ".join(new_path),
|
||||
"length": len(new_path),
|
||||
"severity": "高" if edge_data.get("critical", False) else "中",
|
||||
})
|
||||
|
||||
# 检查受限状态的边
|
||||
if edge_data.get("status") == "受限":
|
||||
if predecessor not in [r["entity"] for r in result["direct_risks"] + result["indirect_risks"]]:
|
||||
risk_entry = {
|
||||
"entity": predecessor,
|
||||
"node_type": node_data.get("node_type", "未知"),
|
||||
"distance": depth + 1,
|
||||
"relation": edge_data.get("relation", ""),
|
||||
"is_critical": edge_data.get("critical", False),
|
||||
"status": "受限",
|
||||
"path": " → ".join(new_path),
|
||||
}
|
||||
if depth == 0:
|
||||
result["direct_risks"].append(risk_entry)
|
||||
else:
|
||||
result["indirect_risks"].append(risk_entry)
|
||||
|
||||
# 继续向上游遍历(最多3层)
|
||||
if depth < 2:
|
||||
queue.append((predecessor, depth + 1, new_path))
|
||||
|
||||
# 计算供应链风险得分
|
||||
result["risk_score"] = _calculate_supply_chain_risk_score(result)
|
||||
|
||||
# 识别关键断裂节点
|
||||
result["critical_nodes"] = _find_critical_nodes(G, target_company)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _calculate_supply_chain_risk_score(contagion_result: dict) -> float:
|
||||
"""计算供应链风险综合得分 (0-100)"""
|
||||
score = 0
|
||||
|
||||
# 直接风险:每个 +25 分,关键供应 +35 分
|
||||
for risk in contagion_result["direct_risks"]:
|
||||
if risk["is_critical"]:
|
||||
score += 35
|
||||
else:
|
||||
score += 25
|
||||
|
||||
# 间接风险:每个 +10 分,关键供应 +15 分
|
||||
for risk in contagion_result["indirect_risks"]:
|
||||
if risk["is_critical"]:
|
||||
score += 15
|
||||
else:
|
||||
score += 10
|
||||
|
||||
return min(score, 100) # 上限 100
|
||||
|
||||
|
||||
def _find_critical_nodes(G: nx.DiGraph, target: str) -> list:
|
||||
"""
|
||||
识别关键断裂节点:如果移除该节点,目标企业将失去关键供应来源
|
||||
"""
|
||||
critical = []
|
||||
predecessors = list(G.predecessors(target))
|
||||
|
||||
for pred in predecessors:
|
||||
edge_data = G.edges[pred, target]
|
||||
if edge_data.get("critical", False):
|
||||
# 检查是否有替代供应商
|
||||
alternatives = sum(
|
||||
1 for p in predecessors
|
||||
if p != pred and G.edges[p, target].get("relation", "") == edge_data.get("relation", "")
|
||||
)
|
||||
critical.append({
|
||||
"node": pred,
|
||||
"relation": edge_data.get("relation", ""),
|
||||
"has_alternative": alternatives > 0,
|
||||
"alternative_count": alternatives,
|
||||
"status": edge_data.get("status", "正常"),
|
||||
})
|
||||
|
||||
return critical
|
||||
|
||||
|
||||
def find_all_risk_paths(G: nx.DiGraph, source: str, target: str, max_depth: int = 4) -> list:
|
||||
"""
|
||||
查找两个节点之间的所有风险路径
|
||||
"""
|
||||
if source not in G or target not in G:
|
||||
return []
|
||||
|
||||
try:
|
||||
paths = list(nx.all_simple_paths(G, source, target, cutoff=max_depth))
|
||||
return [
|
||||
{
|
||||
"path": p,
|
||||
"path_str": " → ".join(p),
|
||||
"length": len(p),
|
||||
}
|
||||
for p in paths
|
||||
]
|
||||
except nx.NetworkXError:
|
||||
return []
|
||||
@@ -0,0 +1,214 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
知识图谱构建引擎
|
||||
基于 NetworkX 构建科创企业供应链风险传染图谱
|
||||
节点类型:企业、供应商、客户、核心人员、制裁实体
|
||||
边类型:供应关系、客户关系、任职关系、投资关系
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import networkx as nx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DATA_DIR = Path(__file__).parent.parent / "data"
|
||||
|
||||
|
||||
def build_graph() -> nx.DiGraph:
|
||||
"""
|
||||
构建完整的科创企业供应链风险知识图谱
|
||||
"""
|
||||
G = nx.DiGraph()
|
||||
|
||||
# 1. 加载企业数据,添加企业节点
|
||||
_add_company_nodes(G)
|
||||
|
||||
# 2. 加载供应链数据,添加关系边
|
||||
_add_supply_chain_edges(G)
|
||||
|
||||
# 3. 加载实体清单,标记受制裁节点
|
||||
_mark_sanctioned_nodes(G)
|
||||
|
||||
logger.info(f"图谱构建完成: {G.number_of_nodes()} 节点, {G.number_of_edges()} 边")
|
||||
return G
|
||||
|
||||
|
||||
def _add_company_nodes(G: nx.DiGraph):
|
||||
"""添加科创板企业节点及其关联的核心人员节点"""
|
||||
filepath = DATA_DIR / "sample_companies.json"
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
companies = json.load(f)
|
||||
|
||||
for company in companies:
|
||||
name = company["short_name"]
|
||||
G.add_node(
|
||||
name,
|
||||
node_type="科创企业",
|
||||
stock_code=company["stock_code"],
|
||||
industry=company["industry"],
|
||||
sector=company["sector"],
|
||||
is_sanctioned=company["compliance"]["entity_list_status"] != "未列入",
|
||||
risk_level="正常",
|
||||
color="#4CAF50", # 默认绿色
|
||||
)
|
||||
|
||||
# 添加核心技术人员节点
|
||||
for person in company.get("core_tech_personnel", []):
|
||||
person_id = f"{person['name']}@{name}"
|
||||
G.add_node(
|
||||
person_id,
|
||||
node_type="核心人员",
|
||||
real_name=person["name"],
|
||||
title=person["title"],
|
||||
status=person["status"],
|
||||
importance=person["importance"],
|
||||
company=name,
|
||||
color="#2196F3", # 蓝色
|
||||
)
|
||||
G.add_edge(
|
||||
person_id, name,
|
||||
relation="任职于",
|
||||
edge_type="personnel",
|
||||
)
|
||||
|
||||
# 添加供应商节点
|
||||
for supplier in company.get("supply_chain", {}).get("key_suppliers", []):
|
||||
supplier_name = supplier.split("(")[0].split("(")[0].strip()
|
||||
if not G.has_node(supplier_name):
|
||||
G.add_node(
|
||||
supplier_name,
|
||||
node_type="供应商",
|
||||
is_sanctioned=False,
|
||||
risk_level="正常",
|
||||
color="#FF9800", # 橙色
|
||||
)
|
||||
G.add_edge(
|
||||
supplier_name, name,
|
||||
relation="供应",
|
||||
detail=supplier,
|
||||
edge_type="supply",
|
||||
)
|
||||
|
||||
# 添加客户节点
|
||||
for customer in company.get("supply_chain", {}).get("key_customers", []):
|
||||
customer_name = customer.split("(")[0].split("(")[0].strip()
|
||||
if not G.has_node(customer_name):
|
||||
G.add_node(
|
||||
customer_name,
|
||||
node_type="客户",
|
||||
is_sanctioned=False,
|
||||
risk_level="正常",
|
||||
color="#9C27B0", # 紫色
|
||||
)
|
||||
G.add_edge(
|
||||
name, customer_name,
|
||||
relation="供货给",
|
||||
edge_type="customer",
|
||||
)
|
||||
|
||||
|
||||
def _add_supply_chain_edges(G: nx.DiGraph):
|
||||
"""从供应链关系文件添加更详细的边"""
|
||||
filepath = DATA_DIR / "supply_chain.json"
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# 添加供应关系
|
||||
for rel in data.get("supply_relations", []):
|
||||
from_node = rel["from"]
|
||||
to_node = rel["to"]
|
||||
|
||||
# 确保节点存在
|
||||
if not G.has_node(from_node):
|
||||
G.add_node(from_node, node_type="供应商", is_sanctioned=False,
|
||||
risk_level="正常", color="#FF9800")
|
||||
if not G.has_node(to_node):
|
||||
G.add_node(to_node, node_type="企业", is_sanctioned=False,
|
||||
risk_level="正常", color="#4CAF50")
|
||||
|
||||
G.add_edge(
|
||||
from_node, to_node,
|
||||
relation=rel["relation"],
|
||||
critical=rel.get("critical", False),
|
||||
status=rel.get("status", "正常"),
|
||||
edge_type="supply",
|
||||
)
|
||||
|
||||
# 添加投资关系
|
||||
for rel in data.get("investment_relations", []):
|
||||
from_node = rel["from"]
|
||||
to_node = rel["to"]
|
||||
|
||||
if not G.has_node(from_node):
|
||||
G.add_node(from_node, node_type="投资方", is_sanctioned=False,
|
||||
risk_level="正常", color="#607D8B")
|
||||
|
||||
G.add_edge(
|
||||
from_node, to_node,
|
||||
relation=rel["relation"],
|
||||
share_ratio=rel.get("share_ratio", 0),
|
||||
edge_type="investment",
|
||||
)
|
||||
|
||||
|
||||
def _mark_sanctioned_nodes(G: nx.DiGraph):
|
||||
"""标记受制裁的节点,并向下游传播风险"""
|
||||
filepath = DATA_DIR / "entity_list.json"
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
entities = json.load(f)
|
||||
|
||||
# 收集所有受制裁实体的名称和别名
|
||||
sanctioned_names = set()
|
||||
for entity in entities:
|
||||
sanctioned_names.add(entity["entity_name"])
|
||||
for alias in entity.get("aliases", []):
|
||||
sanctioned_names.add(alias)
|
||||
|
||||
# 标记图谱中的受制裁节点
|
||||
for node in G.nodes():
|
||||
for sname in sanctioned_names:
|
||||
if node in sname or sname in node:
|
||||
G.nodes[node]["is_sanctioned"] = True
|
||||
G.nodes[node]["risk_level"] = "高危"
|
||||
G.nodes[node]["color"] = "#F44336" # 红色
|
||||
break
|
||||
|
||||
|
||||
def get_node_info(G: nx.DiGraph, node_name: str) -> dict:
|
||||
"""获取节点详细信息"""
|
||||
if node_name not in G:
|
||||
return {"error": f"节点 '{node_name}' 不存在"}
|
||||
|
||||
node_data = dict(G.nodes[node_name])
|
||||
predecessors = list(G.predecessors(node_name))
|
||||
successors = list(G.successors(node_name))
|
||||
|
||||
return {
|
||||
"name": node_name,
|
||||
"attributes": node_data,
|
||||
"upstream": predecessors,
|
||||
"downstream": successors,
|
||||
"degree": G.degree(node_name),
|
||||
}
|
||||
|
||||
|
||||
def get_graph_stats(G: nx.DiGraph) -> dict:
|
||||
"""获取图谱统计信息"""
|
||||
node_types = {}
|
||||
for _, data in G.nodes(data=True):
|
||||
t = data.get("node_type", "未知")
|
||||
node_types[t] = node_types.get(t, 0) + 1
|
||||
|
||||
sanctioned_count = sum(
|
||||
1 for _, data in G.nodes(data=True) if data.get("is_sanctioned", False)
|
||||
)
|
||||
|
||||
return {
|
||||
"total_nodes": G.number_of_nodes(),
|
||||
"total_edges": G.number_of_edges(),
|
||||
"node_types": node_types,
|
||||
"sanctioned_nodes": sanctioned_count,
|
||||
"density": nx.density(G),
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
知识图谱可视化模块
|
||||
使用 pyvis 生成交互式网络图,支持嵌入 Streamlit
|
||||
"""
|
||||
import logging
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import networkx as nx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# 节点类型对应的颜色和形状
|
||||
NODE_STYLES = {
|
||||
"科创企业": {"color": "#4CAF50", "shape": "dot", "size": 30},
|
||||
"供应商": {"color": "#FF9800", "shape": "diamond", "size": 20},
|
||||
"客户": {"color": "#9C27B0", "shape": "triangle", "size": 20},
|
||||
"核心人员": {"color": "#2196F3", "shape": "star", "size": 15},
|
||||
"投资方": {"color": "#607D8B", "shape": "square", "size": 20},
|
||||
"企业": {"color": "#4CAF50", "shape": "dot", "size": 25},
|
||||
}
|
||||
|
||||
# 受制裁节点的样式覆盖
|
||||
SANCTIONED_STYLE = {"color": "#F44336", "size": 35}
|
||||
# 受限边的样式
|
||||
RESTRICTED_EDGE_STYLE = {"color": "#F44336", "dashes": True, "width": 3}
|
||||
|
||||
|
||||
def generate_interactive_graph(
|
||||
G: nx.DiGraph,
|
||||
highlight_company: Optional[str] = None,
|
||||
output_path: Optional[str] = None,
|
||||
height: str = "600px",
|
||||
width: str = "100%",
|
||||
) -> str:
|
||||
"""
|
||||
生成交互式知识图谱 HTML
|
||||
"""
|
||||
try:
|
||||
from pyvis.network import Network
|
||||
except ImportError:
|
||||
logger.error("pyvis 未安装,请运行: pip install pyvis")
|
||||
return _generate_fallback_html(G)
|
||||
|
||||
net = Network(
|
||||
height=height,
|
||||
width=width,
|
||||
directed=True,
|
||||
notebook=False,
|
||||
bgcolor="#1a1a2e",
|
||||
font_color="white",
|
||||
)
|
||||
|
||||
# 物理引擎配置
|
||||
net.set_options("""
|
||||
{
|
||||
"physics": {
|
||||
"forceAtlas2Based": {
|
||||
"gravitationalConstant": -50,
|
||||
"centralGravity": 0.01,
|
||||
"springLength": 150,
|
||||
"springConstant": 0.08
|
||||
},
|
||||
"solver": "forceAtlas2Based",
|
||||
"stabilization": {"iterations": 100}
|
||||
},
|
||||
"interaction": {
|
||||
"hover": true,
|
||||
"tooltipDelay": 200,
|
||||
"navigationButtons": true
|
||||
}
|
||||
}
|
||||
""")
|
||||
|
||||
# 添加节点
|
||||
for node, data in G.nodes(data=True):
|
||||
node_type = data.get("node_type", "企业")
|
||||
style = NODE_STYLES.get(node_type, NODE_STYLES["企业"]).copy()
|
||||
|
||||
# 受制裁节点特殊样式
|
||||
if data.get("is_sanctioned", False):
|
||||
style.update(SANCTIONED_STYLE)
|
||||
|
||||
# 高亮选中企业
|
||||
if highlight_company and node == highlight_company:
|
||||
style["color"] = "#FFD700" # 金色
|
||||
style["size"] = 45
|
||||
style["borderWidth"] = 3
|
||||
|
||||
# 构建标签和悬浮提示
|
||||
label = node.split("@")[0] if "@" in node else node
|
||||
title_parts = [f"<b>{label}</b>", f"类型: {node_type}"]
|
||||
if data.get("is_sanctioned"):
|
||||
title_parts.append("⚠️ <b>已被制裁</b>")
|
||||
if data.get("stock_code"):
|
||||
title_parts.append(f"代码: {data['stock_code']}")
|
||||
if data.get("industry"):
|
||||
title_parts.append(f"行业: {data['industry']}")
|
||||
if data.get("title"):
|
||||
title_parts.append(f"职位: {data['title']}")
|
||||
if data.get("status"):
|
||||
title_parts.append(f"状态: {data['status']}")
|
||||
|
||||
net.add_node(
|
||||
node,
|
||||
label=label,
|
||||
title="<br>".join(title_parts),
|
||||
color=style["color"],
|
||||
shape=style["shape"],
|
||||
size=style["size"],
|
||||
)
|
||||
|
||||
# 添加边
|
||||
for u, v, data in G.edges(data=True):
|
||||
edge_style = {
|
||||
"color": "#666666",
|
||||
"width": 1,
|
||||
"arrows": "to",
|
||||
}
|
||||
|
||||
# 受限边特殊样式
|
||||
if data.get("status") == "受限":
|
||||
edge_style.update(RESTRICTED_EDGE_STYLE)
|
||||
elif data.get("critical", False):
|
||||
edge_style["width"] = 2
|
||||
edge_style["color"] = "#FFC107" # 关键边用黄色
|
||||
|
||||
relation = data.get("relation", "")
|
||||
net.add_edge(
|
||||
u, v,
|
||||
title=relation,
|
||||
label=relation if len(relation) <= 8 else "",
|
||||
**edge_style,
|
||||
)
|
||||
|
||||
# 输出 HTML
|
||||
if output_path is None:
|
||||
output_path = str(Path(tempfile.gettempdir()) / "kg_visualization.html")
|
||||
|
||||
net.save_graph(output_path)
|
||||
|
||||
# 读取 HTML 内容
|
||||
with open(output_path, "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def _generate_fallback_html(G: nx.DiGraph) -> str:
|
||||
"""当 pyvis 不可用时的备用 HTML 可视化"""
|
||||
nodes_info = []
|
||||
for node, data in G.nodes(data=True):
|
||||
label = node.split("@")[0] if "@" in node else node
|
||||
is_sanctioned = data.get("is_sanctioned", False)
|
||||
nodes_info.append(f"<li style='color: {'red' if is_sanctioned else 'green'}'>{label} ({data.get('node_type', '未知')})</li>")
|
||||
|
||||
return f"""
|
||||
<html><body style='background: #1a1a2e; color: white; padding: 20px;'>
|
||||
<h2>📊 知识图谱节点列表(pyvis 未安装,使用简化视图)</h2>
|
||||
<p>节点数: {G.number_of_nodes()} | 边数: {G.number_of_edges()}</p>
|
||||
<ul>{''.join(nodes_info[:50])}</ul>
|
||||
<p style='color: #888;'>安装 pyvis 以获得交互式可视化: pip install pyvis</p>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
|
||||
def get_subgraph_for_company(G: nx.DiGraph, company: str, depth: int = 2) -> nx.DiGraph:
|
||||
"""
|
||||
提取以指定企业为中心的子图(上下游 N 层)
|
||||
"""
|
||||
if company not in G:
|
||||
return nx.DiGraph()
|
||||
|
||||
# 收集相关节点
|
||||
related_nodes = {company}
|
||||
|
||||
# 上游(前驱)
|
||||
current_layer = {company}
|
||||
for _ in range(depth):
|
||||
next_layer = set()
|
||||
for node in current_layer:
|
||||
next_layer.update(G.predecessors(node))
|
||||
related_nodes.update(next_layer)
|
||||
current_layer = next_layer
|
||||
|
||||
# 下游(后继)
|
||||
current_layer = {company}
|
||||
for _ in range(depth):
|
||||
next_layer = set()
|
||||
for node in current_layer:
|
||||
next_layer.update(G.successors(node))
|
||||
related_nodes.update(next_layer)
|
||||
current_layer = next_layer
|
||||
|
||||
return G.subgraph(related_nodes).copy()
|
||||
Reference in New Issue
Block a user