telen / src /telern /concept_graph.py
haidang2405's picture
first commit
e7cfc32 verified
Raw
History Blame Contribute Delete
10.9 kB
"""
Legal Concept Graph — evolving knowledge backbone of TELEN.
Nodes: law entities + key terms extracted via TF-IDF
Edges: agency, temporal, semantic, cross-reference, term-document
GNN: Multi-layer sparse graph convolution
"""
import re
import torch
import torch.nn as nn
import torch.nn.functional as F
from sklearn.feature_extraction.text import TfidfVectorizer
# ═══════════════════════════════════════════════
# GNN Layers
# ═══════════════════════════════════════════════
class GCNLayer(nn.Module):
def __init__(self, in_dim, out_dim, dropout=0.1):
super().__init__()
self.linear = nn.Linear(in_dim, out_dim)
self.dropout = nn.Dropout(dropout)
self.norm = nn.LayerNorm(out_dim)
def forward(self, x, adj):
deg = adj.sum(dim=1).clamp(min=1)
deg_inv_sqrt = deg.pow(-0.5)
norm_adj = deg_inv_sqrt.unsqueeze(1) * adj * deg_inv_sqrt.unsqueeze(0)
x = norm_adj @ x
x = self.linear(x)
x = F.relu(x)
x = self.dropout(x)
x = self.norm(x)
return x
class GNNEncoder(nn.Module):
def __init__(self, dim, n_layers=3, dropout=0.1):
super().__init__()
self.layers = nn.ModuleList([GCNLayer(dim, dim, dropout) for _ in range(n_layers)])
def forward(self, x, adj):
for layer in self.layers:
x = layer(x, adj) + x # residual
return x
# ═══════════════════════════════════════════════
# Legal Concept Graph
# ═══════════════════════════════════════════════
class LegalConceptGraph(nn.Module):
def __init__(self, config):
super().__init__()
self.config = config
self.hidden_dim = config.graph.hidden_dim
self.node_ids = []
self.node_embeddings = None
self.edges = {"cross_ref": [], "agency": [], "temporal": [], "semantic": []}
self._adj_cached = None
self._adj_dirty = True
self.gnn = GNNEncoder(config.graph.hidden_dim, config.graph.gnn_layers, config.graph.gnn_dropout)
@property
def num_nodes(self):
return len(self.node_ids)
@property
def device(self):
return self.gnn.layers[0].linear.weight.device
def add_nodes(self, node_ids, embeddings):
if self.node_embeddings is None:
self.node_embeddings = embeddings
else:
self.node_embeddings = torch.cat([self.node_embeddings, embeddings], dim=0)
self.node_ids.extend(node_ids)
self._adj_dirty = True
def add_edges(self, edge_type, edges):
self.edges[edge_type].extend(edges)
self._adj_dirty = True
def build_adjacency(self):
if not self._adj_dirty and self._adj_cached is not None:
return self._adj_cached
N = self.num_nodes
adj = torch.zeros(N, N, device=self.device)
for edge_type, use in [("cross_ref", self.config.graph.use_cross_ref_edges),
("agency", self.config.graph.use_agency_edges),
("temporal", self.config.graph.use_temporal_edges),
("semantic", self.config.graph.use_semantic_edges)]:
if not use or not self.edges[edge_type]:
continue
valid = [(s, d, w) for s, d, w in self.edges[edge_type] if s < N and d < N]
if not valid:
continue
src = torch.tensor([e[0] for e in valid], device=self.device, dtype=torch.long)
dst = torch.tensor([e[1] for e in valid], device=self.device, dtype=torch.long)
wgt = torch.tensor([e[2] for e in valid], device=self.device, dtype=torch.float)
adj.index_put_((src, dst), wgt, accumulate=True)
adj.index_put_((dst, src), wgt, accumulate=True)
adj = adj + torch.eye(N, device=self.device)
self._adj_cached = adj
self._adj_dirty = False
return adj
def forward(self):
dev = self.device
if self.node_embeddings.device != dev:
self.node_embeddings = self.node_embeddings.to(dev)
adj = self.build_adjacency()
return self.gnn(self.node_embeddings, adj)
def to(self, device):
super().to(device)
if self.node_embeddings is not None:
self.node_embeddings = self.node_embeddings.to(device)
return self
# ═══════════════════════════════════════════════
# Cross-reference extraction
# ═══════════════════════════════════════════════
CROSS_REF_PATTERNS = [
(re.compile(r"(?:theo|theo quy định tại|căn cứ vào|căn cứ)\s+Điều\s+(\d+)\s+(?:của\s+)?(Luật|Bộ luật|Nghị định|Thông tư|Pháp lệnh)\s+([^,.;]+)"), "citation"),
(re.compile(r"(Luật|Bộ luật|Nghị định|Thông tư|Pháp lệnh|Quyết định)\s+(?:số\s+)?([\d]+/[\d]+/[\w-]+)"), "reference"),
(re.compile(r"sửa đổi[,,]\s*bổ sung\s+(?:một số điều của\s+)?(Luật|Nghị định|Thông tư)\s+([^,.;]+)"), "amendment"),
(re.compile(r"(?:thay thế|bãi bỏ)\s+(?:Điều\s+(\d+)\s+(?:của\s+)?)?(Luật|Nghị định|Thông tư)\s+([^,.;]+)"), "replacement"),
]
def extract_key_terms(df, max_terms=200):
texts = [(f"{row['title']} {row['text'][:500]}").replace("_", " ")
for _, row in df.iterrows()]
vectorizer = TfidfVectorizer(max_features=max_terms, ngram_range=(1, 2),
min_df=3, max_df=0.8, token_pattern=r'(?u)\b\w+\b')
tfidf = vectorizer.fit_transform(texts)
scores = tfidf.max(axis=0).toarray().flatten()
return list(vectorizer.get_feature_names_out()[scores.argsort()[::-1][:max_terms]])
def _law_matches_ref(law_id, ref_text):
law_lower = law_id.lower().replace("_", " ").replace("-", " ")
ref_lower = ref_text.lower().replace("_", " ").replace("-", " ")
parts = law_id.split("/")
if len(parts) >= 3:
if parts[2].replace("_", " ") in ref_lower: return True
if len(parts) >= 2 and parts[1] in ref_lower: return True
return False
def build_concept_graph(df, encode_fn, config):
"""Build enhanced concept graph from training data."""
graph = LegalConceptGraph(config)
law_groups = df.groupby("law_id")
law_ids = sorted(law_groups.groups.keys())
N_laws = len(law_ids)
print(f" Building graph: {N_laws} law nodes...")
# Law embeddings
embs = []
for lid in law_ids:
group = law_groups.get_group(lid)
texts = [f"{t}: {txt[:300]}" for t, txt in zip(group["title"], group["text"])]
embs.append(torch.stack([encode_fn(t) for t in texts[:5]]).mean(dim=0))
law_embs = torch.stack(embs)
graph.add_nodes(law_ids, law_embs)
law_id_to_idx = {lid: i for i, lid in enumerate(law_ids)}
# Key term nodes
print(" Extracting key terms...")
key_terms = extract_key_terms(df, max_terms=200)
term_embs = torch.stack([encode_fn(t) for t in key_terms])
graph.add_nodes([f"TERM:{t}" for t in key_terms], term_embs)
print(f" {len(key_terms)} key terms")
# Agency edges
agency_edges = []
for _, group in df.groupby("law_type"):
same = group["law_id"].unique()
for i in range(len(same)):
for j in range(i + 1, len(same)):
if same[i] in law_id_to_idx and same[j] in law_id_to_idx:
agency_edges.append((law_id_to_idx[same[i]], law_id_to_idx[same[j]], 0.3))
graph.add_edges("agency", agency_edges)
print(f" Agency edges: {len(agency_edges)}")
# Temporal edges
temporal_edges = []
for _, group in df.groupby("law_type"):
yl = group.groupby("year")["law_id"].unique()
for y1, y2 in zip(sorted(yl.keys()), sorted(yl.keys())[1:]):
for l1 in yl[y1]:
for l2 in yl[y2]:
if l1 in law_id_to_idx and l2 in law_id_to_idx:
temporal_edges.append((law_id_to_idx[l1], law_id_to_idx[l2], 0.2))
graph.add_edges("temporal", temporal_edges)
print(f" Temporal edges: {len(temporal_edges)}")
# Semantic edges (chunked k-NN)
semantic_k = min(config.graph.semantic_knn, N_laws - 1)
semantic_edges = []
if N_laws > 1:
chunk = 64
for i in range(0, N_laws, chunk):
end = min(i + chunk, N_laws)
sim = F.cosine_similarity(law_embs[i:end].unsqueeze(1), law_embs.unsqueeze(0), dim=2)
for j in range(sim.shape[0]):
sim[j, i + j] = float("-inf")
vals, idx = sim.topk(k=semantic_k, dim=1)
for j in range(sim.shape[0]):
for kk in range(semantic_k):
semantic_edges.append((i + j, idx[j, kk].item(), vals[j, kk].item()))
graph.add_edges("semantic", semantic_edges)
print(f" Semantic edges: {len(semantic_edges)}")
# Cross-reference edges
cross_ref_edges = []
for _, row in df.iterrows():
src = row["law_id"]
if src not in law_id_to_idx: continue
for pattern, etype in CROSS_REF_PATTERNS:
for match in pattern.findall(row["text"]):
match_str = " ".join(match).lower() if isinstance(match, tuple) else str(match).lower()
for tgt in law_ids:
if tgt != src and _law_matches_ref(tgt, match_str):
cross_ref_edges.append((law_id_to_idx[src], law_id_to_idx[tgt], 0.5))
break
graph.add_edges("cross_ref", cross_ref_edges)
print(f" Cross-ref edges: {len(cross_ref_edges)}")
# Term-document edges
term_doc_edges = []
law_texts = [(f"{row['title']} {row['text'][:300]}").replace("_", " ")
for _, row in df.iterrows()]
vec = TfidfVectorizer(vocabulary=key_terms if key_terms else None)
try:
tfidf = vec.fit_transform(law_texts)
for ti, term in enumerate(key_terms):
if ti < tfidf.shape[1]:
col = tfidf[:, ti].toarray().flatten()
for lp in col.argsort()[::-1][:10]:
if col[lp] > 0.1 and lp < N_laws:
term_doc_edges.append((N_laws + ti, lp, float(col[lp])))
except ValueError:
pass
graph.add_edges("semantic", term_doc_edges)
print(f" Term-doc edges: {len(term_doc_edges)}")
print(f" Total: {graph.num_nodes} nodes ({N_laws} laws + {len(key_terms)} terms)")
return graph, law_id_to_idx