| """Aurelius core — structural node embeddings + text/structure fusion. |
| |
| Why structure at all: text embeddings only know what a node *says*, not |
| where it *sits*. Two nodes with unrelated text but heavily overlapping |
| neighbourhoods (a gene and a disease, two tickers that co-move, two |
| modules imported together) are close in structural space while far in |
| text space — exactly the "hidden bridge" signal discover() ranks on, and |
| the reason a bare-text engine misses non-obvious connections. |
| |
| Implementation: DeepWalk-style node2vec — uniform random walks over the |
| stored edge list, then skip-gram with negative sampling (SGNS) trained |
| with batched numpy SGD. Pure numpy on purpose: no gensim/PyG dependency, |
| runs at ingest time on the same free-tier CPU as everything else, and at |
| demo scale (≤ ~50k nodes) finishes in minutes. The upgrade path (biased |
| p/q walks, GraphSAGE for inductive embeddings over node features) slots |
| in behind the same two functions. |
| |
| Fusion: z = [ text_norm ; α · struct_norm ], α per source (config |
| FUSION_ALPHA). Cosine over z blends the two signals with α² relative |
| weight on structure. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import numpy as np |
|
|
| from config import ( |
| N2V_DIM, N2V_WALKS_PER_NODE, N2V_WALK_LENGTH, |
| N2V_WINDOW, N2V_EPOCHS, N2V_NEGATIVES, FUSION_ALPHA, |
| ) |
|
|
|
|
| def random_walks(edges: list[tuple[str, str, float]], |
| walks_per_node: int = N2V_WALKS_PER_NODE, |
| walk_length: int = N2V_WALK_LENGTH, |
| seed: int = 42) -> tuple[list[str], np.ndarray]: |
| """Uniform random walks over an (undirected view of an) edge list. |
| |
| Returns (vocab, walk_matrix[int32 n_walks × walk_length]) with -1 |
| padding for dead-end truncation. Treating the graph as undirected for |
| walk purposes is standard: structural similarity cares about shared |
| neighbourhoods, not edge direction. |
| """ |
| rng = np.random.default_rng(seed) |
| adj: dict[str, list[str]] = {} |
| for s, d, _w in edges: |
| adj.setdefault(s, []).append(d) |
| adj.setdefault(d, []).append(s) |
|
|
| vocab = sorted(adj) |
| index = {v: i for i, v in enumerate(vocab)} |
| adj_idx = [np.array([index[nb] for nb in adj[v]], dtype=np.int32) |
| for v in vocab] |
|
|
| n = len(vocab) |
| walks = np.full((n * walks_per_node, walk_length), -1, dtype=np.int32) |
| row = 0 |
| for start in range(n): |
| for _ in range(walks_per_node): |
| cur = start |
| walks[row, 0] = cur |
| for pos in range(1, walk_length): |
| nbs = adj_idx[cur] |
| if nbs.size == 0: |
| break |
| cur = int(nbs[rng.integers(nbs.size)]) |
| walks[row, pos] = cur |
| row += 1 |
| return vocab, walks |
|
|
|
|
| def sgns_train(vocab: list[str], walks: np.ndarray, |
| dim: int = N2V_DIM, window: int = N2V_WINDOW, |
| epochs: int = N2V_EPOCHS, negatives: int = N2V_NEGATIVES, |
| lr: float = 0.025, batch: int = 8192, |
| seed: int = 42) -> dict[str, np.ndarray]: |
| """Skip-gram with negative sampling over the walk corpus (batched |
| numpy SGD). Returns id → dim-vector.""" |
| rng = np.random.default_rng(seed) |
| n = len(vocab) |
| if n == 0: |
| return {} |
|
|
| |
| centers, contexts = [], [] |
| for offset in range(1, window + 1): |
| c = walks[:, :-offset].ravel() |
| x = walks[:, offset:].ravel() |
| ok = (c >= 0) & (x >= 0) |
| centers.append(c[ok]); contexts.append(x[ok]) |
| C = np.concatenate(centers) |
| X = np.concatenate(contexts) |
| n_pairs = C.size |
| if n_pairs == 0: |
| return {v: np.zeros(dim, dtype=np.float32) for v in vocab} |
|
|
| |
| counts = np.bincount(walks[walks >= 0].ravel(), minlength=n).astype(np.float64) |
| probs = counts ** 0.75 |
| probs /= probs.sum() |
|
|
| W = (rng.random((n, dim), dtype=np.float32) - 0.5) / dim |
| Cw = np.zeros((n, dim), dtype=np.float32) |
|
|
| def sigmoid(z): |
| return 1.0 / (1.0 + np.exp(-np.clip(z, -8, 8))) |
|
|
| order = rng.permutation(n_pairs) |
| for epoch in range(epochs): |
| rng.shuffle(order) |
| for i0 in range(0, n_pairs, batch): |
| idx = order[i0:i0 + batch] |
| c, x = C[idx], X[idx] |
| wc = W[c] |
| |
| xc = Cw[x] |
| g = (sigmoid((wc * xc).sum(1)) - 1.0)[:, None] * lr |
| dwc = g * xc |
| np.add.at(Cw, x, -g * wc) |
| |
| neg = rng.choice(n, size=(idx.size, negatives), p=probs) |
| xn = Cw[neg] |
| gn = sigmoid(np.einsum("bd,bkd->bk", wc, xn)) * lr |
| dwc += np.einsum("bk,bkd->bd", gn, xn) |
| np.add.at(Cw, neg.ravel(), |
| -(gn[..., None] * wc[:, None, :]).reshape(-1, dim)) |
| np.add.at(W, c, -dwc) |
| |
| |
| |
| |
| np.clip(W, -4.0, 4.0, out=W) |
| np.clip(Cw, -4.0, 4.0, out=Cw) |
| print(f"[n2v] epoch {epoch + 1}/{epochs} done ({n_pairs:,} pairs)") |
|
|
| W = np.nan_to_num(W, nan=0.0, posinf=0.0, neginf=0.0) |
| return {v: W[i].copy() for i, v in enumerate(vocab)} |
|
|
|
|
| def node2vec_embeddings(edges: list[tuple[str, str, float]], |
| dim: int = N2V_DIM) -> dict[str, np.ndarray]: |
| """edges (src_id, dst_id, weight) → {node_id: structural vector}.""" |
| if not edges: |
| return {} |
| vocab, walks = random_walks(edges) |
| print(f"[n2v] {len(vocab):,} nodes, {len(edges):,} edges, " |
| f"{walks.shape[0]:,} walks") |
| return sgns_train(vocab, walks, dim=dim) |
|
|
|
|
| def fuse(text_emb: np.ndarray | None, struct_emb: np.ndarray | None, |
| source: str, struct_dim: int = N2V_DIM) -> np.ndarray | None: |
| """z = [text_norm ; α·struct_norm]. Missing halves are zero-padded so |
| fused vectors of one source are always comparable with each other.""" |
| alpha = FUSION_ALPHA.get(source, 0.5) |
| if text_emb is None and struct_emb is None: |
| return None |
|
|
| def _norm(v): |
| v = np.asarray(v, dtype=np.float32) |
| nv = np.linalg.norm(v) |
| return v / nv if nv > 0 else v |
|
|
| if text_emb is not None: |
| t = _norm(text_emb) |
| else: |
| t = None |
| if struct_emb is not None: |
| s = alpha * _norm(struct_emb) |
| else: |
| s = np.zeros(struct_dim, dtype=np.float32) |
| if t is None: |
| |
| |
| return s |
| return np.concatenate([t, s]) |
|
|