vecscope 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vecscope/__init__.py +6 -0
- vecscope/_knn.py +46 -0
- vecscope/cli.py +87 -0
- vecscope/diagnostics/__init__.py +20 -0
- vecscope/diagnostics/anisotropy.py +48 -0
- vecscope/diagnostics/ann_recall.py +25 -0
- vecscope/diagnostics/clusters.py +64 -0
- vecscope/diagnostics/collapse.py +32 -0
- vecscope/diagnostics/geometry.py +120 -0
- vecscope/diagnostics/hubness.py +33 -0
- vecscope/diagnostics/queries.py +65 -0
- vecscope/evidence.py +337 -0
- vecscope/io.py +58 -0
- vecscope/mcp_server.py +64 -0
- vecscope/sweep.py +207 -0
- vecscope-0.1.0.dist-info/METADATA +392 -0
- vecscope-0.1.0.dist-info/RECORD +20 -0
- vecscope-0.1.0.dist-info/WHEEL +4 -0
- vecscope-0.1.0.dist-info/entry_points.txt +3 -0
- vecscope-0.1.0.dist-info/licenses/LICENSE +21 -0
vecscope/__init__.py
ADDED
vecscope/_knn.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Exact k-nearest-neighbor helpers (NumPy only, blocked for memory)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def normalize(x: np.ndarray) -> np.ndarray:
|
|
9
|
+
x = np.asarray(x, dtype=np.float32)
|
|
10
|
+
norms = np.linalg.norm(x, axis=1, keepdims=True)
|
|
11
|
+
norms[norms == 0] = 1.0
|
|
12
|
+
return x / norms
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def exact_knn(
|
|
16
|
+
x: np.ndarray, k: int, metric: str = "cosine", block: int = 2048
|
|
17
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
18
|
+
"""Return (indices, distances) of the k nearest neighbors, excluding self.
|
|
19
|
+
|
|
20
|
+
distances are cosine distance (1 - cos) or squared euclidean.
|
|
21
|
+
"""
|
|
22
|
+
n = x.shape[0]
|
|
23
|
+
k = min(k, n - 1)
|
|
24
|
+
if metric == "cosine":
|
|
25
|
+
q = normalize(x)
|
|
26
|
+
else: # "dot" (negative inner product) or "euclidean"
|
|
27
|
+
q = np.asarray(x, dtype=np.float32)
|
|
28
|
+
sq = (q * q).sum(axis=1)
|
|
29
|
+
idx = np.empty((n, k), dtype=np.int64)
|
|
30
|
+
dist = np.empty((n, k), dtype=np.float32)
|
|
31
|
+
for s in range(0, n, block):
|
|
32
|
+
e = min(s + block, n)
|
|
33
|
+
if metric == "cosine":
|
|
34
|
+
d = 1.0 - q[s:e] @ q.T
|
|
35
|
+
elif metric == "dot":
|
|
36
|
+
d = -(q[s:e] @ q.T)
|
|
37
|
+
else:
|
|
38
|
+
d = sq[s:e, None] - 2.0 * (q[s:e] @ q.T) + sq[None, :]
|
|
39
|
+
rows = np.arange(e - s)
|
|
40
|
+
d[rows, np.arange(s, e)] = np.inf # drop self
|
|
41
|
+
part = np.argpartition(d, k, axis=1)[:, :k]
|
|
42
|
+
pd = np.take_along_axis(d, part, axis=1)
|
|
43
|
+
order = np.argsort(pd, axis=1)
|
|
44
|
+
idx[s:e] = np.take_along_axis(part, order, axis=1)
|
|
45
|
+
dist[s:e] = np.take_along_axis(pd, order, axis=1)
|
|
46
|
+
return idx, dist
|
vecscope/cli.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""vecscope CLI: `vecscope audit embeddings.npy`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from vecscope import __version__
|
|
10
|
+
from vecscope.evidence import audit
|
|
11
|
+
from vecscope.io import load_vectors
|
|
12
|
+
from vecscope.sweep import render_sweep, sweep
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _render_text(ev) -> str:
|
|
16
|
+
mark = {"ok": "ok ", "warn": "WARN", "fail": "FAIL"}
|
|
17
|
+
head = f"vecscope {ev.version} n={ev.n} dim={ev.dim} k={ev.k} verdict={ev.verdict.upper()}"
|
|
18
|
+
if ev.sampled:
|
|
19
|
+
head += f" (sampled from {ev.n_total} rows)"
|
|
20
|
+
if ev.n_queries:
|
|
21
|
+
head += f" queries={ev.n_queries}"
|
|
22
|
+
lines = [head]
|
|
23
|
+
for f in ev.findings:
|
|
24
|
+
lines.append(
|
|
25
|
+
f" [{mark[f.verdict]}] {f.name:<16} {f.metric}={f.value:.4f} ({f.healthy_range})"
|
|
26
|
+
)
|
|
27
|
+
lines.append(f"{'':25}{f.explanation}")
|
|
28
|
+
if ev.clusters:
|
|
29
|
+
lines.append(f" clusters ({len(ev.clusters)}):")
|
|
30
|
+
lines.append(f"{'':4}{'id':>3}{'size':>7}{'share':>7}{'cos2ctr':>9}{'neardup':>9}"
|
|
31
|
+
f"{'hubshare':>10} sample_ids")
|
|
32
|
+
for c in ev.clusters:
|
|
33
|
+
lines.append(f"{'':4}{c['cluster']:>3}{c['size']:>7}{c['share']:>7.1%}"
|
|
34
|
+
f"{c['mean_cosine_to_center']:>9.3f}{c['near_duplicate_ratio']:>9.1%}"
|
|
35
|
+
f"{c['hub_share']:>10.1%} {c['sample_ids']}")
|
|
36
|
+
return "\n".join(lines)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def main(argv: list[str] | None = None) -> int:
|
|
40
|
+
p = argparse.ArgumentParser(prog="vecscope", description="Embedding space evidence for agents.")
|
|
41
|
+
p.add_argument("--version", action="version", version=f"vecscope {__version__}")
|
|
42
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
43
|
+
a = sub.add_parser("audit", help="audit an embedding matrix")
|
|
44
|
+
a.add_argument("path", help=".npy/.npz/.parquet/.jsonl/.csv with one vector per row")
|
|
45
|
+
a.add_argument("-k", type=int, default=10, help="neighbors per item (default 10)")
|
|
46
|
+
a.add_argument("--ann", help="optional .npy of ANN neighbor indices (n x k) to score recall")
|
|
47
|
+
a.add_argument("--queries", help="optional query embeddings (m x dim) for query-side findings")
|
|
48
|
+
a.add_argument("--clusters", type=int, default=0,
|
|
49
|
+
help="attach a per-cluster report with this many clusters (0 = off)")
|
|
50
|
+
a.add_argument("--near-dup", type=float, default=0.99, help="cosine threshold for near-dups")
|
|
51
|
+
a.add_argument("--sample", type=int, default=100_000,
|
|
52
|
+
help="audit a seeded random subset when n exceeds this (0 = all rows)")
|
|
53
|
+
a.add_argument("--json", action="store_true", help="emit JSON evidence instead of text")
|
|
54
|
+
a.add_argument("--fail-on", choices=["warn", "fail"], default="fail",
|
|
55
|
+
help="exit 1 when verdict is at least this level")
|
|
56
|
+
s = sub.add_parser("sweep", help="sweep HNSW/IVF parameters (needs faiss-cpu)")
|
|
57
|
+
s.add_argument("path", help="embedding file, one vector per row")
|
|
58
|
+
s.add_argument("-k", type=int, default=10, help="neighbors per query (default 10)")
|
|
59
|
+
s.add_argument("--queries", help="optional query embeddings; default samples the corpus")
|
|
60
|
+
s.add_argument("--n-queries", type=int, default=1000, help="queries to time (default 1000)")
|
|
61
|
+
s.add_argument("--target-recall", type=float, default=0.95,
|
|
62
|
+
help="recall@k a configuration must reach to be recommended")
|
|
63
|
+
s.add_argument("--device", choices=["auto", "cpu", "gpu"], default="auto",
|
|
64
|
+
help="run IVF and the exact baseline on the GPU when faiss has GPU support "
|
|
65
|
+
"(default auto); HNSW is CPU-only in faiss")
|
|
66
|
+
s.add_argument("--json", action="store_true", help="emit JSON instead of text")
|
|
67
|
+
args = p.parse_args(argv)
|
|
68
|
+
|
|
69
|
+
x = load_vectors(args.path)
|
|
70
|
+
if args.cmd == "sweep":
|
|
71
|
+
q = load_vectors(args.queries) if args.queries else None
|
|
72
|
+
res = sweep(x, k=args.k, queries=q, n_queries=args.n_queries,
|
|
73
|
+
target_recall=args.target_recall, device=args.device)
|
|
74
|
+
print(json.dumps(res.to_dict(), indent=2) if args.json else render_sweep(res))
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
ann = load_vectors(args.ann).astype(int) if args.ann else None
|
|
78
|
+
q = load_vectors(args.queries) if args.queries else None
|
|
79
|
+
ev = audit(x, k=args.k, ann_neighbors=ann, near_dup_cos=args.near_dup,
|
|
80
|
+
sample=args.sample or None, queries=q, clusters=args.clusters)
|
|
81
|
+
print(ev.to_json() if args.json else _render_text(ev))
|
|
82
|
+
level = {"ok": 0, "warn": 1, "fail": 2}
|
|
83
|
+
return 1 if level[ev.verdict] >= level[args.fail_on] else 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
sys.exit(main())
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from vecscope.diagnostics.anisotropy import anisotropy
|
|
2
|
+
from vecscope.diagnostics.ann_recall import ann_recall
|
|
3
|
+
from vecscope.diagnostics.clusters import cluster_report
|
|
4
|
+
from vecscope.diagnostics.collapse import collapse
|
|
5
|
+
from vecscope.diagnostics.geometry import (
|
|
6
|
+
connectivity,
|
|
7
|
+
distance_concentration,
|
|
8
|
+
metric_mismatch,
|
|
9
|
+
norms,
|
|
10
|
+
outliers,
|
|
11
|
+
rogue_dimensions,
|
|
12
|
+
)
|
|
13
|
+
from vecscope.diagnostics.hubness import hubness
|
|
14
|
+
from vecscope.diagnostics.queries import query_audit
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"anisotropy", "ann_recall", "cluster_report", "collapse", "connectivity",
|
|
18
|
+
"distance_concentration", "hubness", "metric_mismatch", "norms", "outliers",
|
|
19
|
+
"query_audit", "rogue_dimensions",
|
|
20
|
+
]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Anisotropy: embeddings crowd into a narrow cone, so every pair looks similar.
|
|
2
|
+
|
|
3
|
+
Evidence: mean cosine of random pairs (0 is isotropic), the norm of the mean unit vector
|
|
4
|
+
(the shared direction every vector carries), the same cosine after subtracting that mean
|
|
5
|
+
(what centering would buy), top principal-component share, effective rank (participation
|
|
6
|
+
ratio of the covariance spectrum).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from vecscope._knn import normalize
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def anisotropy(x: np.ndarray, sample: int = 20000, seed: int = 0) -> dict:
|
|
17
|
+
rng = np.random.default_rng(seed)
|
|
18
|
+
q = normalize(x)
|
|
19
|
+
n, d = q.shape
|
|
20
|
+
m = min(sample, n)
|
|
21
|
+
a = rng.integers(0, n, m)
|
|
22
|
+
b = rng.integers(0, n, m)
|
|
23
|
+
keep = a != b
|
|
24
|
+
mean_cos = float((q[a[keep]] * q[b[keep]]).sum(axis=1).mean())
|
|
25
|
+
mean_vec = q.mean(axis=0)
|
|
26
|
+
qc = normalize(q - mean_vec)
|
|
27
|
+
mean_cos_centered = float((qc[a[keep]] * qc[b[keep]]).sum(axis=1).mean())
|
|
28
|
+
|
|
29
|
+
sub = q[rng.choice(n, min(n, 50000), replace=False)]
|
|
30
|
+
sub = sub - sub.mean(axis=0, keepdims=True)
|
|
31
|
+
s = np.linalg.svd(sub, compute_uv=False)
|
|
32
|
+
var = s**2
|
|
33
|
+
total = var.sum()
|
|
34
|
+
if total <= 0: # constant input
|
|
35
|
+
var = np.zeros_like(var)
|
|
36
|
+
effective_rank = 0.0
|
|
37
|
+
else:
|
|
38
|
+
var = var / total
|
|
39
|
+
effective_rank = float(1.0 / (var**2).sum())
|
|
40
|
+
return {
|
|
41
|
+
"dim": int(d),
|
|
42
|
+
"mean_random_cosine": mean_cos,
|
|
43
|
+
"mean_vector_norm": float(np.linalg.norm(mean_vec)),
|
|
44
|
+
"mean_random_cosine_after_centering": mean_cos_centered,
|
|
45
|
+
"top1_pc_variance_share": float(var[0]) if var.size else 0.0,
|
|
46
|
+
"effective_rank": effective_rank,
|
|
47
|
+
"effective_rank_ratio": effective_rank / float(min(d, sub.shape[0])),
|
|
48
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""ANN recall: does the approximate index actually return the true neighbors?
|
|
2
|
+
|
|
3
|
+
Compare neighbors returned by an ANN index (HNSW, IVF, ...) with exact k-NN.
|
|
4
|
+
Evidence: recall@k, per-query worst recall, share of queries below a floor.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def ann_recall(exact_knn_idx: np.ndarray, ann_idx: np.ndarray, floor: float = 0.8) -> dict:
|
|
13
|
+
k = min(exact_knn_idx.shape[1], ann_idx.shape[1])
|
|
14
|
+
exact = exact_knn_idx[:, :k]
|
|
15
|
+
approx = ann_idx[:, :k]
|
|
16
|
+
hits = np.array([len(set(e.tolist()) & set(a.tolist())) for e, a in zip(exact, approx)])
|
|
17
|
+
per_query = hits / k
|
|
18
|
+
return {
|
|
19
|
+
"k": int(k),
|
|
20
|
+
"recall_at_k": float(per_query.mean()),
|
|
21
|
+
"recall_p05": float(np.quantile(per_query, 0.05)),
|
|
22
|
+
"min_recall": float(per_query.min()),
|
|
23
|
+
"queries_below_floor_ratio": float((per_query < floor).mean()),
|
|
24
|
+
"floor": floor,
|
|
25
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Per-cluster report: where in the space do the problems live?
|
|
2
|
+
|
|
3
|
+
A fixed-seed spherical k-means splits the corpus into a few regions; for each one the report
|
|
4
|
+
gives size, tightness, near-duplicate share, how often its items act as hubs, and sample ids
|
|
5
|
+
the agent can look up. No verdicts: this is a map, not a test.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from vecscope._knn import normalize
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def spherical_kmeans(q: np.ndarray, n_clusters: int, seed: int = 0, iters: int = 15) -> np.ndarray:
|
|
16
|
+
rng = np.random.default_rng(seed)
|
|
17
|
+
centers = q[rng.choice(q.shape[0], n_clusters, replace=False)].copy()
|
|
18
|
+
labels = np.zeros(q.shape[0], dtype=np.int64)
|
|
19
|
+
for _ in range(iters):
|
|
20
|
+
sims = q @ centers.T
|
|
21
|
+
new = sims.argmax(axis=1)
|
|
22
|
+
if np.array_equal(new, labels) and _ > 0:
|
|
23
|
+
break
|
|
24
|
+
labels = new
|
|
25
|
+
for c in range(n_clusters):
|
|
26
|
+
members = q[labels == c]
|
|
27
|
+
if members.size:
|
|
28
|
+
centers[c] = members.mean(axis=0)
|
|
29
|
+
centers = normalize(centers)
|
|
30
|
+
return labels
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def cluster_report(
|
|
34
|
+
x: np.ndarray,
|
|
35
|
+
n_clusters: int,
|
|
36
|
+
knn_idx: np.ndarray,
|
|
37
|
+
knn_dist: np.ndarray,
|
|
38
|
+
near_dup_cos: float,
|
|
39
|
+
seed: int = 0,
|
|
40
|
+
sample_ids: int = 5,
|
|
41
|
+
) -> list[dict]:
|
|
42
|
+
q = normalize(x)
|
|
43
|
+
n = q.shape[0]
|
|
44
|
+
n_clusters = max(1, min(n_clusters, n // 10))
|
|
45
|
+
labels = spherical_kmeans(q, n_clusters, seed)
|
|
46
|
+
nk = np.bincount(knn_idx.ravel(), minlength=n).astype(np.float64)
|
|
47
|
+
nn_cos = 1.0 - knn_dist[:, 0]
|
|
48
|
+
report = []
|
|
49
|
+
for c in range(n_clusters):
|
|
50
|
+
m = np.nonzero(labels == c)[0]
|
|
51
|
+
if m.size == 0:
|
|
52
|
+
continue
|
|
53
|
+
center = normalize(q[m].mean(axis=0, keepdims=True))[0]
|
|
54
|
+
report.append({
|
|
55
|
+
"cluster": int(c),
|
|
56
|
+
"size": int(m.size),
|
|
57
|
+
"share": float(m.size / n),
|
|
58
|
+
"mean_cosine_to_center": float((q[m] @ center).mean()),
|
|
59
|
+
"near_duplicate_ratio": float((nn_cos[m] >= near_dup_cos).mean()),
|
|
60
|
+
"hub_share": float(nk[m].sum() / nk.sum()) if nk.sum() > 0 else 0.0,
|
|
61
|
+
"sample_ids": [int(i) for i in m[np.argsort(-(q[m] @ center))[:sample_ids]]],
|
|
62
|
+
})
|
|
63
|
+
report.sort(key=lambda r: -r["size"])
|
|
64
|
+
return report
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Collapse: many vectors are (near) identical, so retrieval cannot tell them apart.
|
|
2
|
+
|
|
3
|
+
Evidence: exact duplicate ratio, near-duplicate ratio (cos > threshold), and the
|
|
4
|
+
share of items whose nearest neighbor is essentially itself.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from vecscope._knn import exact_knn
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def collapse(
|
|
15
|
+
x: np.ndarray, near_dup_cos: float = 0.99, knn_dist: np.ndarray | None = None
|
|
16
|
+
) -> dict:
|
|
17
|
+
n = x.shape[0]
|
|
18
|
+
if knn_dist is None:
|
|
19
|
+
_, knn_dist = exact_knn(x, 1)
|
|
20
|
+
nn_cos = 1.0 - knn_dist[:, 0]
|
|
21
|
+
exact = float((nn_cos >= 1.0 - 1e-6).mean())
|
|
22
|
+
near = float((nn_cos >= near_dup_cos).mean())
|
|
23
|
+
unique_rows = np.unique(np.asarray(x, dtype=np.float32).round(6), axis=0).shape[0]
|
|
24
|
+
return {
|
|
25
|
+
"n": int(n),
|
|
26
|
+
"exact_duplicate_ratio": 1.0 - unique_rows / n,
|
|
27
|
+
"near_duplicate_ratio": near,
|
|
28
|
+
"nn_cosine_median": float(np.median(nn_cos)),
|
|
29
|
+
"nn_cosine_p95": float(np.quantile(nn_cos, 0.95)),
|
|
30
|
+
"near_dup_threshold": near_dup_cos,
|
|
31
|
+
"exact_nn_match_ratio": exact,
|
|
32
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Geometry diagnostics derived from the shared exact k-NN pass.
|
|
2
|
+
|
|
3
|
+
- rogue_dimensions: a handful of dimensions hold most of the variance, so cosine is decided
|
|
4
|
+
by them alone (Timkey & van Schijndel, 2021).
|
|
5
|
+
- norms: vector lengths vary or vanish, which breaks inner-product retrieval.
|
|
6
|
+
- metric_mismatch: cosine top-k and inner-product top-k disagree, so the index metric matters.
|
|
7
|
+
- distance_concentration: all distances look alike, the nearest neighbor is barely nearer than
|
|
8
|
+
a random item (relative contrast, Aggarwal et al., 2001).
|
|
9
|
+
- outliers: items whose nearest neighbor is unusually far, typically junk or foreign content.
|
|
10
|
+
- connectivity: small islands in the k-NN graph that graph-based ANN attaches badly or never reaches.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def rogue_dimensions(x: np.ndarray, top: int = 3) -> dict:
|
|
19
|
+
var = np.asarray(x, dtype=np.float64).var(axis=0)
|
|
20
|
+
total = var.sum()
|
|
21
|
+
if total <= 0: # constant matrix: every dimension is equally (un)informative
|
|
22
|
+
return {"top1_dim_variance_share": 0.0, "top3_dim_variance_share": 0.0,
|
|
23
|
+
"expected_share": 1.0 / x.shape[1], "rogue_dims": [0, 1, 2][: x.shape[1]]}
|
|
24
|
+
share = var / total
|
|
25
|
+
order = np.argsort(share)[::-1]
|
|
26
|
+
return {
|
|
27
|
+
"top1_dim_variance_share": float(share[order[0]]),
|
|
28
|
+
"top3_dim_variance_share": float(share[order[:top]].sum()),
|
|
29
|
+
"expected_share": 1.0 / x.shape[1],
|
|
30
|
+
"rogue_dims": [int(i) for i in order[:top]],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def norms(x: np.ndarray) -> dict:
|
|
35
|
+
n = np.linalg.norm(np.asarray(x, dtype=np.float64), axis=1)
|
|
36
|
+
mean = n.mean()
|
|
37
|
+
return {
|
|
38
|
+
"norm_cv": float(n.std() / mean) if mean > 0 else 0.0,
|
|
39
|
+
"zero_norm_ratio": float((n == 0).mean()),
|
|
40
|
+
"norm_min": float(n.min()),
|
|
41
|
+
"norm_median": float(np.median(n)),
|
|
42
|
+
"norm_max": float(n.max()),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def metric_mismatch(cos_knn: np.ndarray, dot_knn: np.ndarray) -> dict:
|
|
47
|
+
k = min(cos_knn.shape[1], dot_knn.shape[1])
|
|
48
|
+
a = np.sort(cos_knn[:, :k], axis=1)
|
|
49
|
+
b = np.sort(dot_knn[:, :k], axis=1)
|
|
50
|
+
overlap = np.array([np.intersect1d(r, s, assume_unique=True).size for r, s in zip(a, b)]) / k
|
|
51
|
+
return {
|
|
52
|
+
"k": int(k),
|
|
53
|
+
"topk_overlap": float(overlap.mean()),
|
|
54
|
+
"queries_fully_agreeing_ratio": float((overlap == 1.0).mean()),
|
|
55
|
+
"min_overlap": float(overlap.min()),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def distance_concentration(nn_dist: np.ndarray, random_pair_cos: float) -> dict:
|
|
60
|
+
"""Relative contrast on cosine distance: mean random distance over median NN distance."""
|
|
61
|
+
nn = np.asarray(nn_dist, dtype=np.float64)
|
|
62
|
+
distinct = nn[nn > 1e-4] # exact duplicates (float32 noise ~1e-7) would pin the median at 0
|
|
63
|
+
median_nn = float(np.median(distinct)) if distinct.size else 0.0
|
|
64
|
+
mean_random = float(max(1.0 - random_pair_cos, 1e-9))
|
|
65
|
+
return {
|
|
66
|
+
"relative_contrast": mean_random / max(median_nn, 1e-6),
|
|
67
|
+
"median_nn_distance": median_nn,
|
|
68
|
+
"mean_random_distance": mean_random,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def outliers(nn_dist: np.ndarray, factor: float = 3.0, sample_ids: int = 10) -> dict:
|
|
73
|
+
nn = np.asarray(nn_dist, dtype=np.float64)
|
|
74
|
+
distinct = nn[nn > 1e-4] if (nn > 1e-4).any() else nn
|
|
75
|
+
median = float(np.median(distinct))
|
|
76
|
+
mad = float(np.median(np.abs(distinct - median)))
|
|
77
|
+
cutoff = median + factor * 1.4826 * mad if mad > 0 else median * (1 + factor)
|
|
78
|
+
mask = nn > cutoff
|
|
79
|
+
worst = np.argsort(nn)[::-1][:sample_ids]
|
|
80
|
+
return {
|
|
81
|
+
"outlier_ratio": float(mask.mean()),
|
|
82
|
+
"cutoff_nn_distance": float(cutoff),
|
|
83
|
+
"median_nn_distance": median,
|
|
84
|
+
"sample_ids": [int(i) for i in worst if mask[i]],
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def connectivity(knn: np.ndarray, min_island_frac: float = 0.005, max_iter: int = 1000) -> dict:
|
|
89
|
+
"""Connected components of the symmetrized k-NN graph via min-label propagation.
|
|
90
|
+
|
|
91
|
+
Large components are ordinary cluster structure. The retrieval risk is small islands:
|
|
92
|
+
components smaller than max(k + 1, min_island_frac * n), which graph-based ANN tends to
|
|
93
|
+
attach to the wrong region or never reach.
|
|
94
|
+
"""
|
|
95
|
+
n, k = knn.shape
|
|
96
|
+
src = np.repeat(np.arange(n), k)
|
|
97
|
+
dst = knn.ravel()
|
|
98
|
+
a = np.concatenate([src, dst])
|
|
99
|
+
b = np.concatenate([dst, src])
|
|
100
|
+
label = np.arange(n)
|
|
101
|
+
for _ in range(max_iter):
|
|
102
|
+
new = label.copy()
|
|
103
|
+
np.minimum.at(new, a, label[b])
|
|
104
|
+
if np.array_equal(new, label):
|
|
105
|
+
break
|
|
106
|
+
label = new
|
|
107
|
+
labels, counts = np.unique(label, return_counts=True)
|
|
108
|
+
min_size = max(k + 1, int(np.ceil(min_island_frac * n)))
|
|
109
|
+
small = counts < min_size
|
|
110
|
+
island_items = int(counts[small].sum())
|
|
111
|
+
island_labels = labels[small]
|
|
112
|
+
sample = np.nonzero(np.isin(label, island_labels))[0][:10]
|
|
113
|
+
return {
|
|
114
|
+
"island_ratio": island_items / n,
|
|
115
|
+
"n_components": int(counts.size),
|
|
116
|
+
"n_islands": int(small.sum()),
|
|
117
|
+
"min_island_size": int(min_size),
|
|
118
|
+
"largest_component_ratio": float(counts.max() / n),
|
|
119
|
+
"sample_ids": [int(i) for i in sample],
|
|
120
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Hubness: a few points appear in everyone's k-NN list (hubs), others in nobody's (anti-hubs).
|
|
2
|
+
|
|
3
|
+
High hubness means the same chunks top every query regardless of relevance.
|
|
4
|
+
Evidence: skewness of the k-occurrence distribution N_k, hub share, anti-hub ratio.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from vecscope._knn import exact_knn
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def hubness(x: np.ndarray, k: int = 10, knn: np.ndarray | None = None) -> dict:
|
|
15
|
+
n = x.shape[0]
|
|
16
|
+
if knn is None:
|
|
17
|
+
knn, _ = exact_knn(x, k)
|
|
18
|
+
k = knn.shape[1]
|
|
19
|
+
nk = np.bincount(knn.ravel(), minlength=n).astype(np.float64)
|
|
20
|
+
mean, std = nk.mean(), nk.std()
|
|
21
|
+
skew = float(((nk - mean) ** 3).mean() / (std**3)) if std > 0 else 0.0
|
|
22
|
+
top = np.sort(nk)[::-1]
|
|
23
|
+
top1pct = max(1, int(np.ceil(n * 0.01)))
|
|
24
|
+
hub_share = float(top[:top1pct].sum() / nk.sum())
|
|
25
|
+
antihub_ratio = float((nk == 0).mean())
|
|
26
|
+
return {
|
|
27
|
+
"k": int(k),
|
|
28
|
+
"skewness": skew,
|
|
29
|
+
"max_k_occurrence": int(nk.max()),
|
|
30
|
+
"expected_k_occurrence": float(k),
|
|
31
|
+
"hub_share_top1pct": hub_share,
|
|
32
|
+
"antihub_ratio": antihub_ratio,
|
|
33
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Query-side evidence: do the queries live in the same space as the corpus?
|
|
2
|
+
|
|
3
|
+
- ood_queries: queries whose nearest corpus item is farther than almost every corpus item is
|
|
4
|
+
from its own nearest neighbor. Typical causes: asymmetric models used without the query
|
|
5
|
+
prefix, a different model for queries, or queries about things the corpus does not cover.
|
|
6
|
+
- query_hubness: skewness of how often each corpus item appears in query top-k lists. High
|
|
7
|
+
values mean the same few items answer everything.
|
|
8
|
+
- coverage (details): share of corpus items that any query reaches in its top-k.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from vecscope._knn import normalize
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def query_knn(corpus: np.ndarray, queries: np.ndarray, k: int, block: int = 2048):
|
|
19
|
+
c = normalize(corpus)
|
|
20
|
+
q = normalize(queries)
|
|
21
|
+
k = min(k, c.shape[0])
|
|
22
|
+
idx = np.empty((q.shape[0], k), dtype=np.int64)
|
|
23
|
+
dist = np.empty((q.shape[0], k), dtype=np.float32)
|
|
24
|
+
for s in range(0, q.shape[0], block):
|
|
25
|
+
e = min(s + block, q.shape[0])
|
|
26
|
+
d = 1.0 - q[s:e] @ c.T
|
|
27
|
+
part = np.argpartition(d, k - 1, axis=1)[:, :k]
|
|
28
|
+
pd = np.take_along_axis(d, part, axis=1)
|
|
29
|
+
order = np.argsort(pd, axis=1)
|
|
30
|
+
idx[s:e] = np.take_along_axis(part, order, axis=1)
|
|
31
|
+
dist[s:e] = np.take_along_axis(pd, order, axis=1)
|
|
32
|
+
return idx, dist
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def query_audit(
|
|
36
|
+
corpus: np.ndarray,
|
|
37
|
+
queries: np.ndarray,
|
|
38
|
+
k: int,
|
|
39
|
+
corpus_nn_dist: np.ndarray,
|
|
40
|
+
sample_ids: int = 10,
|
|
41
|
+
) -> dict:
|
|
42
|
+
idx, dist = query_knn(corpus, queries, k)
|
|
43
|
+
n_corpus = corpus.shape[0]
|
|
44
|
+
corpus_nn = np.asarray(corpus_nn_dist, dtype=np.float64)
|
|
45
|
+
corpus_nn = corpus_nn[corpus_nn > 1e-4] if (corpus_nn > 1e-4).any() else corpus_nn
|
|
46
|
+
cutoff = float(np.quantile(corpus_nn, 0.99))
|
|
47
|
+
nearest = dist[:, 0].astype(np.float64)
|
|
48
|
+
ood = nearest > cutoff
|
|
49
|
+
worst = np.argsort(nearest)[::-1][:sample_ids]
|
|
50
|
+
|
|
51
|
+
nk = np.bincount(idx.ravel(), minlength=n_corpus).astype(np.float64)
|
|
52
|
+
std = nk.std()
|
|
53
|
+
skew = float(((nk - nk.mean()) ** 3).mean() / std**3) if std > 0 else 0.0
|
|
54
|
+
return {
|
|
55
|
+
"n_queries": int(queries.shape[0]),
|
|
56
|
+
"k": int(idx.shape[1]),
|
|
57
|
+
"ood_query_ratio": float(ood.mean()),
|
|
58
|
+
"ood_cutoff_distance": cutoff,
|
|
59
|
+
"median_query_nn_distance": float(np.median(nearest)),
|
|
60
|
+
"median_corpus_nn_distance": float(np.median(corpus_nn)),
|
|
61
|
+
"ood_sample_ids": [int(i) for i in worst if ood[i]],
|
|
62
|
+
"query_hubness_skewness": skew,
|
|
63
|
+
"max_query_k_occurrence": int(nk.max()),
|
|
64
|
+
"coverage": float((nk > 0).mean()),
|
|
65
|
+
}
|