creative-writing-llm / src /diversity.py
Pranav2748's picture
Add src
cbc33fe verified
Raw
History Blame Contribute Delete
8.06 kB
"""
Diversity math over an embedding group. Pure numpy, no torch.
Given L2-normalized embeddings E = [e_1..e_G] (shape G x d) for ONE prompt's
generation group, we expose two families of per-sample diversity credit:
1. deviation d_i = mean_{j!=i} (1 - cos(e_i, e_j))
-- pairwise, cheap, but blind to cluster structure:
two tight far-apart clusters score as high as a spread.
2. marginal m_i = logdet(L) - logdet(L_{-i})
contribution -- leave-one-out volume credit under the cosine kernel.
A duplicate contributes ~nothing (its direction is already
spanned), so this DOES see cluster structure.
Both are per-sample. This is deliberate: a set-level scalar (e.g. logdet(L)
itself) is constant within a GRPO group, so its within-group std is 0 and the
normalized advantage is identically 0. It cannot train anything. Every
quantity here varies across i within a group.
"""
from __future__ import annotations
import numpy as np
EPS_JITTER = 1e-3
def l2_normalize(E: np.ndarray, axis: int = -1) -> np.ndarray:
"""Row-normalize; zero rows are left as zeros rather than NaN."""
E = np.asarray(E, dtype=np.float64)
n = np.linalg.norm(E, axis=axis, keepdims=True)
return E / np.maximum(n, 1e-12)
def cosine_kernel(E: np.ndarray, eps: float = EPS_JITTER) -> np.ndarray:
"""L = E E^T + eps*I.
E must be L2-normalized, so diag(E E^T) = 1 and L is a correlation-like
PSD matrix. The eps jitter keeps logdet finite when rows are collinear
(exact duplicates make E E^T singular).
"""
E = np.asarray(E, dtype=np.float64)
L = E @ E.T
L = 0.5 * (L + L.T) # kill float asymmetry before Cholesky
return L + eps * np.eye(L.shape[0], dtype=np.float64)
def pairwise_deviation(E: np.ndarray) -> np.ndarray:
"""d_i = mean_{j != i} (1 - cos(e_i, e_j)). Shape (G,).
G == 1 has no off-diagonal terms; we return 0.0 (a lone sample has no
measurable deviation, and 0 is the neutral value for the reward).
"""
E = np.asarray(E, dtype=np.float64)
G = E.shape[0]
if G < 2:
return np.zeros(G, dtype=np.float64)
S = E @ E.T
D = 1.0 - S
np.fill_diagonal(D, 0.0)
return D.sum(axis=1) / (G - 1)
def _logdet_psd(L: np.ndarray) -> float:
"""logdet via Cholesky; falls back to slogdet if L drifts non-PD."""
try:
c = np.linalg.cholesky(L)
return float(2.0 * np.sum(np.log(np.diag(c))))
except np.linalg.LinAlgError:
sign, ld = np.linalg.slogdet(L)
if sign <= 0:
return float("-inf")
return float(ld)
def logdet_volume(E: np.ndarray, eps: float = EPS_JITTER) -> float:
"""D(Y) = logdet(E E^T + eps I). Set-level scalar.
Report this as a METRIC. Never hand it to GRPO as a per-sample reward:
it is identical for every i in the group -> zero advantage.
"""
if np.asarray(E).shape[0] == 0:
return 0.0
return _logdet_psd(cosine_kernel(E, eps))
def marginal_contributions(E: np.ndarray, eps: float = EPS_JITTER) -> np.ndarray:
"""m_i = logdet(L) - logdet(L_{-i}), shape (G,).
Computed by G explicit leave-one-out logdets. G <= 16 here, so this is
~16 Cholesky calls on a 15x15 matrix -- utterly negligible next to a
single LLM forward pass. Rank-one downdate machinery would be a
micro-optimization with real numerical-stability downside; not worth it.
Interpretation: m_i is the log-volume the group loses by dropping i.
A duplicate has m_i ~ log(eps) -> large negative. An orthogonal direction
has m_i ~ log(1+eps) ~ 0. So m_i is a *penalty scale*: higher (closer to
0) means "this sample carries a direction nothing else covers".
"""
E = np.asarray(E, dtype=np.float64)
G = E.shape[0]
if G < 2:
return np.zeros(G, dtype=np.float64)
L = cosine_kernel(E, eps)
full = _logdet_psd(L)
out = np.empty(G, dtype=np.float64)
idx = np.arange(G)
for i in range(G):
keep = idx[idx != i]
out[i] = full - _logdet_psd(L[np.ix_(keep, keep)])
return out
def effective_rank(E: np.ndarray, eps: float = 1e-12) -> float:
"""exp(Shannon entropy of the normalized Gram spectrum). Roy & Vetterli.
Reads as "how many distinct directions does this set effectively span":
1.0 for identical stories, G for mutually orthogonal ones, ~2 for two tight
clusters. Reported as a METRIC, never used as a reward (it is set-level, so
it is constant within a group and would produce zero advantage).
This exists because silhouette-selected k-means turned out to be unusable as
a mode counter on this data: a fully collapsed set and a fully spread set
both peak near silhouette 0.20 (at k=4 and k=7 respectively), because
k-means partitions isotropic data happily regardless of spread. Effective
rank has no k to select and degrades gracefully.
"""
E = np.asarray(E, dtype=np.float64)
if E.shape[0] == 0:
return 0.0
if E.shape[0] == 1:
return 1.0
L = E @ E.T
w = np.linalg.eigvalsh(0.5 * (L + L.T))
w = np.clip(w, 0.0, None)
s = w.sum()
if s <= 0:
return 1.0
p = w / s
p = p[p > eps]
return float(np.exp(-(p * np.log(p)).sum()))
def zscore(x: np.ndarray, ddof: int = 0) -> np.ndarray:
"""Per-group z-score. Constant input -> all zeros (not NaN).
Used on m_i before it enters the reward: raw m_i lives on a log scale
with a long negative tail (a duplicate pair can hit log(1e-3) ~ -6.9),
which would otherwise dominate the quality term.
"""
x = np.asarray(x, dtype=np.float64)
if x.size == 0:
return x
s = x.std(ddof=ddof)
if not np.isfinite(s) or s < 1e-12:
return np.zeros_like(x)
return (x - x.mean()) / s
def scale_to_reference(x: np.ndarray, ref_scale: float = 1.0) -> np.ndarray:
"""Rescale x to have unit-ish spread times ref_scale, preserving mean 0.
Deviation d_i lives in [0, 2] but in practice clusters in [0.1, 0.5] for
same-prompt stories -- an order of magnitude below judge quality (0-10).
Feeding raw d_i with alpha=0.5 would make the diversity term invisible.
"""
return zscore(x) * float(ref_scale)
def greedy_diverse_subset(
quality: np.ndarray,
E: np.ndarray,
k: int,
lam: float = 1.0,
eps: float = EPS_JITTER,
) -> list[int]:
"""Greedily pick k indices maximizing sum(quality) + lam * logdet(L_S).
Standard submodular greedy: the objective is monotone-ish and DPP logdet
is submodular, so greedy carries the usual (1 - 1/e) flavor of guarantee.
Used to build E3's multi-positive chosen sets.
Returns indices into the rows of E, in selection order.
"""
quality = np.asarray(quality, dtype=np.float64)
E = np.asarray(E, dtype=np.float64)
G = E.shape[0]
k = int(min(k, G))
if k <= 0:
return []
L = cosine_kernel(E, eps)
chosen: list[int] = []
for _ in range(k):
best_gain, best_i = -np.inf, -1
for i in range(G):
if i in chosen:
continue
cand = chosen + [i]
vol = _logdet_psd(L[np.ix_(cand, cand)])
gain = quality[cand].sum() + lam * vol
if gain > best_gain:
best_gain, best_i = gain, i
if best_i < 0:
break
chosen.append(best_i)
return chosen
def group_metrics(E: np.ndarray, eps: float = EPS_JITTER) -> dict:
"""Bundle of reporting metrics for one group. Metrics only, not rewards."""
E = np.asarray(E, dtype=np.float64)
G = E.shape[0]
if G < 2:
return {"n": G, "mean_pairwise_dist": 0.0, "logdet": 0.0,
"mean_marginal": 0.0, "min_marginal": 0.0}
d = pairwise_deviation(E)
m = marginal_contributions(E, eps)
return {
"n": G,
"mean_pairwise_dist": float(d.mean()),
"logdet": float(logdet_volume(E, eps)),
"mean_marginal": float(m.mean()),
"min_marginal": float(m.min()),
}