Arora 2012 - Recover Algorithms RecoverKL and RecoverL2
Summary
With anchors known, every row of the row-normalised co-occurrence matrix lies in the convex hull of the anchor rows, and the mixing weights equal . Solving a K-dimensional simplex-constrained regression per word, then applying Bayes’ rule, yields without matrix inversion.
Main Content
Input. of word-word co-occurrence, normalised to sum 1: in the population limit and . Row-normalised . Construction (Appendix D.1): for each document with counts and length , use , which has expectation (removes the diagonal word-with-itself terms), averaged over documents.
Convex-hull structure
For an anchor word : , since and in an admixture. For any word : with , . Hence every row of lies in the convex hull of the anchor rows.
Original Recover (Alg. 2) and RecoverKL (Alg. 3)
Alg. 2 (Arora et al. 2012b): permute so anchors come first, compute , solve , then and - matrix inversion; can return negative values. RecoverKL: for each word , subject to , (tolerance ); then with and columns of normalised to give (Bayes: ); . RecoverL2: same with squared loss; objective where is and computed once, so the optimisation is independent of .
Details: the per-word problems are independent (parallel) and solved by exponentiated gradient (Alg. 5, Kivinen and Warmuth 1995) with a KKT-based convergence test (stationarity, primal/dual feasibility, complementary slackness , equivalent to a small primal-dual gap). RecoverL2 runtime with - iterations. KL form is maximum likelihood on the co-occurrence counts but does not force the admixture constraint. For LDA, gives the Dirichlet hyperparameters up to scale; the scale is chosen by grid search on training likelihood (more reliable than reading it from ).
Examples
Population-level recovery (runnable)
Synthetic , with exact anchors; exact ; anchors via FastAnchorWords; recovery with simplex-constrained least squares gives L1 error 0 (to numerical precision) per topic.
import numpy as np
from scipy.optimize import nnls
rng = np.random.default_rng(0)
V, K = 40, 3
A = rng.dirichlet(np.ones(V) * 0.2, K).T
A[:K, :] = 0; A[np.arange(K), np.arange(K)] = 0.05; A /= A.sum(0) # words 0..K-1 are anchors
R = 0.6 * np.eye(K) + 0.4 / K
Q = A @ R @ A.T; Q /= Q.sum(); Qb = Q / Q.sum(1, keepdims=True)
def fast_anchor_words(Qb, K):
S = [int(np.argmax((Qb**2).sum(1)))]
for _ in range(K - 1):
B, _ = np.linalg.qr(Qb[S].T)
resid = Qb.T - B @ (B.T @ Qb.T)
S.append(int(np.argmax((resid**2).sum(0))))
return S
def recover_l2(Q, Qb, S, lam=1e3):
pw = Q.sum(1); T = Qb[S].T; K = len(S)
C = np.array([nnls(np.vstack([T, lam*np.ones(K)]), np.append(Qb[i], lam))[0] for i in range(Q.shape[0])])
C /= C.sum(1, keepdims=True)
A_hat = C * pw[:, None]
return A_hat / A_hat.sum(0)
S = fast_anchor_words(Qb, K)
print(sorted(S), np.abs(recover_l2(Q, Qb, S) - A[:, S]).sum(0).round(3)) # anchors found; ~0 errorConnections
Why it works: Arora 2012 - Separability and Anchor Words. Anchor selection: Arora 2012 - FastAnchorWords Algorithm and Guarantees. Compare to likelihood-based fitting: Blei 2003 - Variational Inference and Parameter Estimation.