arrowspace-tuner 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.
@@ -0,0 +1,40 @@
1
+ """
2
+ arrowspace_tuner — hyperparameter discovery for ArrowSpace.
3
+
4
+ Quickstart
5
+ ----------
6
+ import numpy as np
7
+ import arrowspace_tuner as arrowspace
8
+
9
+ embeddings = np.load("corpus.npy")
10
+
11
+ # one-liner: auto-discover eps, k, tau
12
+ aspace, gl = arrowspace.optuna(embeddings)
13
+
14
+ # power-user: full control + post-run inspection
15
+ from arrowspace_tuner import EpsTuner
16
+
17
+ tuner = EpsTuner(n_trials=100, sample_n=10_000, eps_low=0.5, eps_high=3.0)
18
+ aspace, gl = tuner.fit(embeddings)
19
+ print(tuner.best_params) # {"eps": 1.2, "k": 14, "tau": 0.8}
20
+ print(tuner.best_score)
21
+ tuner.save_report() # requires pip install arrowspace-tuner[report]
22
+ """
23
+ from .api import optuna
24
+ from .tuner import EpsTuner
25
+
26
+ # Power-user exports: config dataclasses for advanced customisation
27
+ from .core import BuildParams, StudyConfig
28
+
29
+ __version__ = "0.1.0"
30
+
31
+ __all__ = [
32
+ # primary public API
33
+ "optuna",
34
+ "EpsTuner",
35
+ # config — for power users
36
+ "BuildParams",
37
+ "StudyConfig",
38
+ # version
39
+ "__version__",
40
+ ]
@@ -0,0 +1,132 @@
1
+ """
2
+ api.py — one-liner convenience function for hyperparameter discovery.
3
+
4
+ This module exists solely to satisfy the acceptance criteria:
5
+
6
+ aspace, gl = arrowspace.optuna(embeddings)
7
+
8
+ It is a thin shim over EpsTuner with sensible defaults.
9
+ For any non-trivial use case, instantiate EpsTuner directly.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import numpy as np
14
+
15
+ from .core.config import _DEFAULT_N_TRIALS
16
+ from .tuner import EpsTuner
17
+
18
+
19
+ def optuna(
20
+ embeddings: np.ndarray,
21
+ *,
22
+ n_trials: int = _DEFAULT_N_TRIALS,
23
+ sample_n: int | None = 5_000,
24
+ seed: int = 54,
25
+ study_name: str = "arrowspace_fstar",
26
+ storage: str | None = None,
27
+ eps_low: float = 0.3,
28
+ eps_high: float = 4.0,
29
+ k_low: int = 3,
30
+ k_high: int = 40,
31
+ tau_low: float = 0.1,
32
+ tau_high: float = 1.0,
33
+ n_probe: int = 50,
34
+ ) -> tuple[object, object]:
35
+ """
36
+ Auto-discover eps, k, and tau and return a ready-to-use (aspace, gl) pair.
37
+
38
+ This is the simplest entry point to arrowspace_tuner. It runs an Optuna
39
+ study with default settings and returns the ArrowSpace index built with
40
+ the best hyperparameters found.
41
+
42
+ Defaults are tuned for speed on large corpora (> 50k items):
43
+ - sample_n=5_000 gives a 33x speedup over full-corpus trials with
44
+ identical best params found (validated on a 50k CVE corpus).
45
+ - n_probe=50 is sufficient to rank parameter regions reliably.
46
+ - The final build after the study always uses the full corpus.
47
+
48
+ Parameters
49
+ ----------
50
+ embeddings : np.ndarray
51
+ Shape (N, D) float64 corpus embeddings.
52
+ n_trials : int
53
+ Number of Optuna trials. Default 15.
54
+ sample_n : int | None
55
+ Subsample size per trial. Default 5_000. None = full corpus.
56
+ Recommended for large corpora (> 50k items).
57
+ seed : int
58
+ Random seed for reproducibility.
59
+ study_name : str
60
+ Optuna study identifier.
61
+ storage : str | None
62
+ Optuna storage URI for persistence. None = in-memory.
63
+ Use "sqlite:///tune.db" to resume interrupted runs.
64
+ eps_low, eps_high : float
65
+ Log-scale search bounds for eps.
66
+ k_low, k_high : int
67
+ Search bounds for k.
68
+ tau_low, tau_high : float
69
+ Search bounds for tau.
70
+ n_probe : int
71
+ Number of anchor queries per trial for the MRR proxy. Default 50.
72
+
73
+ Returns
74
+ -------
75
+ aspace : ArrowSpace
76
+ ArrowSpace index built with the best hyperparameters found.
77
+ gl : GraphLaplacian
78
+ Corresponding graph Laplacian.
79
+
80
+ Examples
81
+ --------
82
+ Minimal usage — matches the acceptance criteria exactly:
83
+
84
+ import numpy as np
85
+ import arrowspace_tuner as arrowspace
86
+
87
+ embeddings = np.load("corpus.npy")
88
+ aspace, gl = arrowspace.optuna(embeddings)
89
+
90
+ results = aspace.search(query_embedding, gl, tau=0.8)
91
+
92
+ With a custom search range:
93
+
94
+ aspace, gl = arrowspace.optuna(
95
+ embeddings,
96
+ n_trials=30,
97
+ sample_n=10_000,
98
+ eps_low=0.5,
99
+ eps_high=3.0,
100
+ )
101
+
102
+ Inspecting the study after the fact:
103
+
104
+ from arrowspace_tuner import EpsTuner
105
+
106
+ tuner = EpsTuner(n_trials=15, sample_n=5_000)
107
+ aspace, gl = tuner.fit(embeddings)
108
+ print(tuner.best_params)
109
+ tuner.save_report()
110
+
111
+ Resuming an interrupted run:
112
+
113
+ aspace, gl = arrowspace.optuna(
114
+ embeddings,
115
+ storage="sqlite:///tune.db",
116
+ )
117
+ """
118
+ tuner = EpsTuner(
119
+ n_trials = n_trials,
120
+ sample_n = sample_n,
121
+ seed = seed,
122
+ study_name = study_name,
123
+ storage = storage,
124
+ eps_low = eps_low,
125
+ eps_high = eps_high,
126
+ k_low = k_low,
127
+ k_high = k_high,
128
+ tau_low = tau_low,
129
+ tau_high = tau_high,
130
+ n_probe = n_probe,
131
+ )
132
+ return tuner.fit(embeddings)
@@ -0,0 +1,25 @@
1
+ """
2
+ arrowspace_tuner.core — internal building blocks.
3
+
4
+ This subpackage is not part of the public API.
5
+ Import from arrowspace_tuner directly:
6
+
7
+ from arrowspace_tuner import EpsTuner, optuna
8
+ from arrowspace_tuner import StudyConfig, BuildParams # for power users
9
+ """
10
+ from .config import BuildParams, StudyConfig
11
+ from .graph import fiedler_normalized, gl_to_scipy
12
+ from .objective import build_and_score, make_objective
13
+ __version__ = "0.1.0"
14
+
15
+ __all__ = [
16
+ # config
17
+ "BuildParams",
18
+ "StudyConfig",
19
+ # graph
20
+ "fiedler_normalized",
21
+ "gl_to_scipy",
22
+ # objective
23
+ "build_and_score",
24
+ "make_objective",
25
+ ]
@@ -0,0 +1,121 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ # Single source of truth for the default number of trials.
6
+ # Referenced by StudyConfig, EpsTuner.__init__, and api.optuna().
7
+ _DEFAULT_N_TRIALS: int = 15
8
+
9
+
10
+ @dataclass
11
+ class BuildParams:
12
+ """
13
+ Parameters passed to ArrowSpaceBuilder for a single trial build.
14
+
15
+ Attributes
16
+ ----------
17
+ eps : float
18
+ Neighbourhood radius for graph construction.
19
+ Primary hyperparameter being optimised.
20
+ k : int
21
+ Number of nearest neighbours used when building the graph.
22
+ topk : int
23
+ Number of results returned by search. Automatically set to k // 2
24
+ during optimisation; can be overridden for the final build.
25
+ p : float
26
+ Minkowski distance exponent (2.0 = Euclidean).
27
+ sigma : float | None
28
+ Optional Gaussian kernel bandwidth. None = auto.
29
+ max_clusters : int
30
+ Upper bound on the number of clusters fed to the builder.
31
+ cluster_radius : float
32
+ Squared L2 threshold for cluster creation.
33
+ sampling_rate : float
34
+ Fraction of embeddings used per trial build (1.0 = all).
35
+ """
36
+
37
+ eps: float = 0.8
38
+ k: int = 10
39
+ topk: int = 5
40
+ p: float = 2.0
41
+ sigma: float | None = None
42
+ max_clusters: int = 5000
43
+ cluster_radius: float = 0.42
44
+ sampling_rate: float = 1.0
45
+
46
+ def to_dict(self) -> dict:
47
+ """Return graph_params dict expected by ArrowSpaceBuilder.build()."""
48
+ return {
49
+ "eps": self.eps,
50
+ "k": self.k,
51
+ "topk": self.topk,
52
+ "p": self.p,
53
+ "sigma": self.sigma,
54
+ }
55
+
56
+
57
+ @dataclass
58
+ class StudyConfig:
59
+ """
60
+ Configuration for the Optuna study loop.
61
+
62
+ Attributes
63
+ ----------
64
+ n_trials : int
65
+ Number of Optuna trials to run. Default: 15.
66
+ sample_n : int | None
67
+ Subsample this many embeddings per trial for speed.
68
+ None = use all embeddings every trial.
69
+ Recommended: 5_000 for corpora > 50k items (33x speedup,
70
+ identical best params found vs full-corpus run).
71
+ seed : int
72
+ Random seed for reproducibility.
73
+ study_name : str
74
+ Optuna study identifier. Used as folder name in reporter output.
75
+ storage : str | None
76
+ Optuna storage URL (e.g. "sqlite:///optuna.db"). None = in-memory.
77
+
78
+ Search space — graph structure
79
+ ------------------------------
80
+ eps_low, eps_high : float
81
+ Log-scale bounds for eps search.
82
+ k_low, k_high : int
83
+ Bounds for k (nearest neighbours) search.
84
+
85
+ Search space — retrieval
86
+ ------------------------
87
+ tau_low, tau_high : float
88
+ Bounds for tau search. tau controls the ArrowSpace search
89
+ temperature passed to search_batch(). Optimising tau alongside
90
+ eps and k ensures the graph is evaluated at its best retrieval
91
+ operating point, not an arbitrary fixed tau.
92
+
93
+ MRR proxy
94
+ ---------
95
+ n_probe : int
96
+ Number of corpus items used as query anchors per trial when
97
+ computing the spectral MRR-Top0 proxy. Higher = more accurate
98
+ estimate, slower trials. 50 is a good default for most corpora
99
+ when using sample_n; use 200 for full-corpus runs.
100
+ """
101
+
102
+ n_trials: int = _DEFAULT_N_TRIALS
103
+ sample_n: int | None = None
104
+ seed: int = 54
105
+ study_name: str = "arrowspace_tuner"
106
+ storage: str | None = None
107
+
108
+ # Search space — graph
109
+ eps_low: float = 0.3
110
+ eps_high: float = 4.0
111
+ k_low: int = 3
112
+ k_high: int = 40
113
+
114
+ # Search space — retrieval
115
+ tau_low: float = 0.1
116
+ tau_high: float = 1.0
117
+
118
+ # MRR proxy
119
+ n_probe: int = 200
120
+ max_clusters: int = 50
121
+ cluster_radius: float = 0.5
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ import numpy as np
6
+ import scipy.sparse as sp
7
+ import scipy.sparse.linalg as spla
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ def gl_to_scipy(gl: object) -> sp.csr_matrix:
13
+ """
14
+ Convert a PyGraphLaplacian (from the ArrowSpace Rust extension) to a
15
+ SciPy CSR sparse matrix.
16
+
17
+ Parameters
18
+ ----------
19
+ gl : PyGraphLaplacian
20
+ The graph Laplacian returned by ArrowSpaceBuilder.build().
21
+
22
+ Returns
23
+ -------
24
+ sp.csr_matrix
25
+ The Laplacian as a SciPy sparse matrix, ready for eigendecomposition.
26
+ """
27
+ raw = gl.to_csr() # returns (data, indices, indptr, shape)
28
+ shape = gl.shape()
29
+ data = np.asarray(raw[0], dtype=np.float64)
30
+ indices = np.asarray(raw[1], dtype=np.int32)
31
+ indptr = np.asarray(raw[2], dtype=np.int32)
32
+ return sp.csr_matrix((data, indices, indptr), shape=shape)
33
+
34
+
35
+ def _get_nnz(gl: object) -> int:
36
+ """Return the number of non-zero entries in the graph Laplacian."""
37
+ return len(gl.to_csr()[0])
38
+
39
+
40
+ def fiedler_normalized(gl: object) -> float:
41
+ """
42
+ Compute the normalised Fiedler value (λ₂) of the graph Laplacian.
43
+
44
+ The Fiedler value is the second-smallest eigenvalue of the normalised
45
+ Laplacian L_norm = D^{-1/2} L D^{-1/2}. It measures algebraic
46
+ connectivity: λ₂ = 0 means the graph is disconnected, higher values
47
+ indicate stronger connectivity.
48
+
49
+ Used as one factor in the F** = fiedler × var(λ) objective.
50
+
51
+ Parameters
52
+ ----------
53
+ gl : PyGraphLaplacian
54
+ The graph Laplacian returned by ArrowSpaceBuilder.build().
55
+
56
+ Returns
57
+ -------
58
+ float
59
+ λ₂ ∈ [0, 1]. Returns 0.0 on degenerate or disconnected graphs,
60
+ and on any numerical failure.
61
+ """
62
+ try:
63
+ nnz = _get_nnz(gl)
64
+ shape = gl.shape()
65
+ n = shape[0]
66
+
67
+ # Degenerate guard: fewer edges than nodes → nearly empty graph
68
+ if nnz <= n:
69
+ logger.warning(
70
+ "Degenerate graph NNZ=%d <= N=%d — returning 0.0", nnz, n
71
+ )
72
+ return 0.0
73
+
74
+ L = gl_to_scipy(gl)
75
+
76
+ # Normalise: L_norm = D^{-1/2} L D^{-1/2}
77
+ diag = np.array(L.diagonal(), dtype=np.float64)
78
+ safe_diag = np.where(diag > 1e-12, diag, 1e-12)
79
+ d_inv_sqrt = sp.diags(1.0 / np.sqrt(safe_diag))
80
+ L_norm = d_inv_sqrt @ L @ d_inv_sqrt
81
+
82
+ # k=2: we need the two smallest eigenvalues; λ₁ ≈ 0, λ₂ = Fiedler
83
+ vals = spla.eigsh(
84
+ L_norm,
85
+ k=2,
86
+ which="SM",
87
+ return_eigenvectors=False,
88
+ tol=1e-6,
89
+ maxiter=2000,
90
+ )
91
+ fiedler = max(0.0, float(sorted(np.real(vals))[1]))
92
+
93
+ logger.debug(
94
+ "fiedler_normalized: λ₂=%.6f NNZ=%d N=%d", fiedler, nnz, n
95
+ )
96
+ return fiedler
97
+
98
+ except Exception as exc:
99
+ logger.warning("fiedler_normalized failed: %s", exc, exc_info=True)
100
+ return 0.0