import argparse
import json
import numpy as np
import scipy.sparse as sp
from scipy.sparse.linalg import svds
def load(path):
links = []
with open(path) as f:
for line in f:
d = json.loads(line)
links.append((d["h"], d["f"], d["t"]))
return links
def pagerank(A, alpha=0.85, iters=200, tol=1e-12):
n = A.shape[0]
out = np.asarray(A.sum(axis=1)).ravel()
dang = out == 0
osafe = np.where(out == 0, 1.0, out)
Dinv = sp.diags(1.0 / osafe)
P = (Dinv @ A).T phi = np.full(n, 1.0 / n)
for _ in range(iters):
prev = phi.copy()
dmass = phi[dang].sum()
phi = alpha * (P @ phi + dmass * phi) + (1 - alpha) / n
phi /= phi.sum()
if np.abs(phi - prev).sum() < tol:
break
return phi
def ppmi(A):
out = np.asarray(A.sum(axis=1)).ravel()
inc = np.asarray(A.sum(axis=0)).ravel()
total = A.sum()
C = A.tocoo()
denom = np.maximum(out[C.row] * inc[C.col], 1e-300)
vals = np.log(C.data * total / denom)
vals = np.maximum(vals, 0.0)
return sp.coo_matrix((vals, (C.row, C.col)), shape=A.shape).tocsr()
def svd_embed(M, k):
U, S, Vt = svds(M.astype(np.float64), k=k, which="LM", maxiter=1000)
order = np.argsort(-S)
S = S[order]
U = U[:, order]
V = Vt.T[:, order]
return U * np.sqrt(S), V * np.sqrt(S), S
def auc(scores, labels):
order = np.argsort(scores, kind="mergesort")
ranks = np.empty(len(scores))
ranks[order] = np.arange(1, len(scores) + 1)
s_sorted = scores[order]
i = 0
while i < len(s_sorted):
j = i
while j + 1 < len(s_sorted) and s_sorted[j + 1] == s_sorted[i]:
j += 1
if j > i:
ranks[order[i : j + 1]] = (i + 1 + j + 1) / 2
i = j + 1
pos = labels == 1
npos, nneg = pos.sum(), (~pos).sum()
return (ranks[pos].sum() - npos * (npos + 1) / 2) / (npos * nneg)
def average_precision(scores, labels):
order = np.argsort(-scores)
lab = labels[order]
tp = np.cumsum(lab)
prec = tp / np.arange(1, len(lab) + 1)
return float((prec * lab).sum() / lab.sum())
def main():
ap = argparse.ArgumentParser()
ap.add_argument("links")
ap.add_argument("--k", type=int, default=64)
ap.add_argument("--random-split", action="store_true")
ap.add_argument("--neg-mult", type=int, default=5)
args = ap.parse_args()
rng = np.random.default_rng(7)
links = load(args.links)
heights = np.array([h for h, _, _ in links])
order = np.argsort(heights, kind="stable")
heights = heights[order]
pairs = [(links[i][1], links[i][2]) for i in order]
n = len(pairs)
split = int(n * 0.9)
if args.random_split:
perm = rng.permutation(n)
train_idx, test_idx = perm[:split], perm[split:]
else:
train_idx, test_idx = np.arange(split), np.arange(split, n)
pid = {}
def intern(p):
if p not in pid:
pid[p] = len(pid)
return pid[p]
rows, cols, data = [], [], []
cnt = {}
for i in train_idx:
a, b = pairs[i]
ia, ib = intern(a), intern(b)
key = (ia, ib)
cnt[key] = cnt.get(key, 0) + 1
for (ia, ib), c in cnt.items():
rows.append(ia)
cols.append(ib)
data.append(float(c))
nn = len(pid)
A = sp.csr_matrix((data, (rows, cols)), shape=(nn, nn))
print(f"train: {len(train_idx)} links ยท {A.nnz} unique pairs ยท {nn} particles")
print(f"test: {len(test_idx)} links (split={'random' if args.random_split else 'temporal by height'})")
full_pairs = set()
for a, b in pairs:
full_pairs.add((a, b))
test_pairs, novel = [], 0
for i in test_idx:
a, b = pairs[i]
if a in pid and b in pid:
ia, ib = pid[a], pid[b]
test_pairs.append((ia, ib))
if (ia, ib) not in cnt and (ib, ia) not in cnt:
novel += 1
print(f"scorable test pairs: {len(test_pairs)} ({novel} novel in either direction)")
inv = [None] * nn
for p, i in pid.items():
inv[i] = p
negs = set()
while len(negs) < len(test_pairs) * args.neg_mult:
i = int(rng.integers(0, nn))
j = int(rng.integers(0, nn))
if i == j:
continue
if (inv[i], inv[j]) in full_pairs or (inv[j], inv[i]) in full_pairs:
continue
if (i, j) in cnt:
continue
negs.add((i, j))
negs = list(negs)
phi = pagerank(A)
out_deg = np.asarray(A.sum(axis=1)).ravel()
in_deg = np.asarray(A.sum(axis=0)).ravel()
sq = np.sqrt(phi)
M_tru = sp.diags(sq) @ A @ sp.diags(sq)
E_tru, _, _ = svd_embed(M_tru, args.k)
E_src, E_tgt, S = svd_embed(M_tru, args.k)
P = ppmi(A)
P_src, P_tgt, _ = svd_embed(P, args.k)
print(f"svd k={args.k} ยท sigma_top={S[0]:.4f} ยท sigma_k={S[-1]:.4f}")
At = A.T.tocsr()
def aa_score(i, j):
nz = set(A.indices[A.indptr[i] : A.indptr[i + 1]])
cand = At.indices[At.indptr[j] : At.indptr[j + 1]]
s = 0.0
for z in cand:
if z in nz:
dz = out_deg[z] + in_deg[z]
if dz > 1:
s += 1.0 / np.log(dz)
return s
models = {
"pa (phi_i*phi_j)": lambda i, j: phi[i] * phi[j],
"tru-E (sym)": lambda i, j: float(E_tru[i] @ E_tru[j]),
"directed (U,V)": lambda i, j: float(E_src[i] @ E_tgt[j]),
"ppmi (U,V)": lambda i, j: float(P_src[i] @ P_tgt[j]),
}
for name, f in models.items():
pos_s = np.array([f(i, j) for i, j in test_pairs])
neg_s = np.array([f(i, j) for i, j in negs])
scores = np.concatenate([pos_s, neg_s])
labels = np.concatenate([np.ones(len(pos_s)), np.zeros(len(neg_s))])
a = auc(scores, labels)
ap_ = average_precision(scores, labels)
nov_pairs = [(i, j) for (i, j) in test_pairs
if (i, j) not in cnt and (j, i) not in cnt]
if nov_pairs:
ns = int(min(len(negs), len(nov_pairs) * args.neg_mult))
ps = np.array([f(i, j) for i, j in nov_pairs])
ss = np.concatenate([ps, neg_s[:ns]])
ll = np.concatenate([np.ones(len(ps)), np.zeros(ns)])
an = auc(ss, ll)
else:
an = float("nan")
print(f"{name:22} AUC {a:.4f} ยท AP {ap_:.4f} ยท novel-AUC {an:.4f} (n={len(nov_pairs)})")
def zsc(x):
s = x.std()
return (x - x.mean()) / (s if s > 0 else 1.0)
for k2 in [16, args.k]:
P2 = ppmi(A)
Ps2, Pt2, _ = svd_embed(P2, k2)
pos_pa = np.array([phi[i] * phi[j] for i, j in test_pairs])
neg_pa = np.array([phi[i] * phi[j] for i, j in negs])
pos_sp = np.array([float(Ps2[i] @ Pt2[j]) for i, j in test_pairs])
neg_sp = np.array([float(Ps2[i] @ Pt2[j]) for i, j in negs])
for hname, hs in [
("pa*ppmi", (pos_pa * pos_sp, neg_pa * neg_sp)),
("pa+ppmi(z)", (zsc(pos_pa) + zsc(pos_sp), zsc(neg_pa) + zsc(neg_sp))),
]:
scores = np.concatenate(hs)
labels = np.concatenate([np.ones(len(hs[0])), np.zeros(len(hs[1]))])
print(f"hybrid k={k2:<3} {hname:12} AUC {auc(scores, labels):.4f} ยท AP {average_precision(scores, labels):.4f}")
A2 = (A @ A).tocsr()
for gamma in [0.25, 0.5, 1.0]:
M2 = sp.diags(sq) @ (A + gamma * A2) @ sp.diags(sq)
E2s, E2t, _ = svd_embed(M2, args.k)
for name, f in {
f"tru-E2 g={gamma} (sym)": lambda i, j: float(E2s[i] @ E2s[j]),
f"directed2 g={gamma}": lambda i, j: float(E2s[i] @ E2t[j]),
}.items():
pos_s = np.array([f(i, j) for i, j in test_pairs])
neg_s = np.array([f(i, j) for i, j in negs])
scores = np.concatenate([pos_s, neg_s])
labels = np.concatenate([np.ones(len(pos_s)), np.zeros(len(neg_s))])
a = auc(scores, labels)
ap_ = average_precision(scores, labels)
nov_pairs = [(i, j) for (i, j) in test_pairs
if (i, j) not in cnt and (j, i) not in cnt]
if nov_pairs:
ns = int(min(len(negs), len(nov_pairs) * args.neg_mult))
ps = np.array([f(i, j) for i, j in nov_pairs])
ss = np.concatenate([ps, neg_s[:ns]])
ll = np.concatenate([np.ones(len(ps)), np.zeros(ns)])
an = auc(ss, ll)
else:
an = float("nan")
print(f"{name:22} AUC {a:.4f} ยท AP {ap_:.4f} ยท novel-AUC {an:.4f} (n={len(nov_pairs)})")
sub = test_pairs[: min(800, len(test_pairs))]
pos_s = np.array([aa_score(i, j) for i, j in sub])
ns = len(sub) * args.neg_mult
neg_s = np.array([aa_score(i, j) for i, j in negs[:ns]])
scores = np.concatenate([pos_s, neg_s])
labels = np.concatenate([np.ones(len(pos_s)), np.zeros(len(neg_s))])
print(f"{'adamic-adar (sub)':22} AUC {auc(scores, labels):.4f} ยท AP {average_precision(scores, labels):.4f} (n={len(sub)})")
if __name__ == "__main__":
main()