Skip to content

Commit 335240b

Browse files
Sweep index size and negative ratio in run_grid_search
Index size is the highest-leverage knob for tuning, but run_grid_search could only sweep top_k and min_score. With subsample() available, size and ratio can be swept too, and cheaply: reshaping the index costs about a millisecond, while re-scoring costs seconds. The loop is organised around what each setting costs. Size and ratio reshape the index (cheap) and become an outer loop; top_k forces a re-score (expensive); thresholds and aggregators stay free on the cached scores. Rows now carry the requested n_positive and neg_to_pos_ratio plus the actual n_positive_actual and n_negative_actual counts. The actual counts matter because a request is clipped when the index is smaller than asked for - without them two rows can look identical while describing different indices. Backward compatible: both new arguments default to None, meaning one pass using the index exactly as given. subsample() is not called at all in that case, so any index-like object keeps working and existing behaviour is unchanged. Also fixes a latent NameError: simulation.py referenced LOG without importing logging or defining it. Nothing had triggered it because the module had no logging calls until this one. Documented in the docstring, and worth calling out for reviewers: the cost is len(sizes) x len(ratios) x len(top_k) scoring passes, and the observation texts are re-encoded on every pass even though their embeddings do not depend on the index at all. Letting callers pass pre-computed sample embeddings would collapse that to roughly one encoding pass. Deliberately out of scope here. Adds 5 tests covering the default no-subsample path, both axes together and separately, clipped counts, and the per-configuration progress logging. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent ded6bd5 commit 335240b

2 files changed

Lines changed: 256 additions & 21 deletions

File tree

src/sentinel/simulation.py

Lines changed: 123 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
rows = compare_aggregators(scored)
5757
"""
5858

59+
import logging
5960
from dataclasses import dataclass
6061
from typing import TYPE_CHECKING, Callable, Dict, List, Mapping, Optional, Sequence
6162

@@ -75,6 +76,8 @@
7576
# stays lightweight (numpy-only) and does not pull in torch / transformers.
7677
from sentinel.sentinel_local_index import SentinelLocalIndex
7778

79+
LOG = logging.getLogger(__name__)
80+
7881

7982
# A convenient name -> function map of the built-in summarize metrics, so a
8083
# caller (or a notebook) can iterate over "all aggregators" in one line.
@@ -515,6 +518,21 @@ def compare_aggregators(
515518
]
516519

517520

521+
def _row_count(embeddings: object) -> Optional[int]:
522+
"""Return the number of rows in an embedding tensor, or None if unavailable.
523+
524+
Args:
525+
embeddings: A tensor-like object, or None.
526+
527+
Returns:
528+
The row count, or None when the object has no usable shape.
529+
"""
530+
shape = getattr(embeddings, "shape", None)
531+
if shape is None or len(shape) == 0:
532+
return None
533+
return int(shape[0])
534+
535+
518536
def run_grid_search(
519537
index: "SentinelLocalIndex",
520538
groups: Sequence[LabeledGroup],
@@ -525,13 +543,33 @@ def run_grid_search(
525543
top_n: Optional[int] = None,
526544
decision_threshold: Optional[float] = None,
527545
show_progress_bar: bool = False,
546+
n_positive_values: Optional[Sequence[int]] = None,
547+
neg_to_pos_ratios: Optional[Sequence[float]] = None,
548+
index_seed: Optional[int] = None,
528549
) -> List[Dict[str, float]]:
529550
"""Sweep hyperparameters and summarize metrics, returning a flat result table.
530551
531-
For each ``top_k`` the groups are scored once (the expensive step), then
532-
every combination of ``min_score_values`` x ``aggregators`` is evaluated
533-
cheaply. The returned rows are easy to turn into a ``pandas.DataFrame`` for
534-
plotting.
552+
The sweep is organised around what each setting costs. Index size and ratio
553+
reshape the index (cheap, via
554+
:meth:`~sentinel.sentinel_local_index.SentinelLocalIndex.subsample`), ``top_k``
555+
forces a re-scoring (expensive), and thresholds and aggregators are evaluated
556+
for free on the cached scores::
557+
558+
for n_positive in n_positive_values: # cheap: subsample()
559+
for ratio in neg_to_pos_ratios: # cheap: subsample()
560+
for top_k in top_k_values: # EXPENSIVE: re-scores
561+
for min_score in min_score_values: # cheap
562+
for aggregator in aggregators: # cheap
563+
564+
The returned rows are easy to turn into a ``pandas.DataFrame`` for plotting.
565+
566+
Cost warning: the number of scoring passes is
567+
``len(n_positive_values) x len(neg_to_pos_ratios) x len(top_k_values)``. A 7x7
568+
size/ratio grid with three ``top_k`` values is 147 full passes. Note that the
569+
observation texts are re-encoded on every pass even though their embeddings do
570+
not depend on the index at all, so most of that cost is recomputing identical
571+
numbers. Letting callers pass pre-computed sample embeddings would collapse it
572+
to roughly one encoding pass; that is a separate change.
535573
536574
Args:
537575
index: A loaded :class:`~sentinel.sentinel_local_index.SentinelLocalIndex`.
@@ -543,30 +581,94 @@ def run_grid_search(
543581
decision_threshold: Cutoff for the classification family (``None`` =
544582
best-F1).
545583
show_progress_bar: Whether to show the encoder progress bar.
584+
n_positive_values: Index sizes to try, as counts of positive examples.
585+
``None`` (the default) means one pass using the index exactly as given.
586+
neg_to_pos_ratios: Negative-to-positive ratios to try. ``None`` (the
587+
default) means one pass using the index exactly as given.
588+
index_seed: Seed for the subsampling, so each index configuration is
589+
reproducible across runs.
546590
547591
Returns:
548-
A list of metric dicts, one per ``(top_k, min_score_to_consider,
549-
aggregator)`` combination. Each row also includes a ``top_k`` key.
592+
A list of metric dicts, one per ``(n_positive, neg_to_pos_ratio, top_k,
593+
min_score_to_consider, aggregator)`` combination. Each row also includes
594+
``top_k``, the requested ``n_positive`` and ``neg_to_pos_ratio``, and the
595+
actual ``n_positive_actual`` / ``n_negative_actual`` counts. The actual
596+
counts matter because a request is clipped when the index is smaller than
597+
asked for - without them two rows can look identical while describing
598+
different indices.
550599
"""
551600
if aggregators is None:
552601
aggregators = DEFAULT_AGGREGATORS
553602

603+
# None means "one pass, use the index exactly as given", which reproduces the
604+
# behaviour from before these axes existed.
605+
positive_options: Sequence[Optional[int]] = (
606+
list(n_positive_values) if n_positive_values is not None else [None]
607+
)
608+
ratio_options: Sequence[Optional[float]] = (
609+
list(neg_to_pos_ratios) if neg_to_pos_ratios is not None else [None]
610+
)
611+
total_configurations = len(positive_options) * len(ratio_options)
612+
554613
rows: List[Dict[str, float]] = []
555-
for top_k in top_k_values:
556-
scored = score_groups(
557-
index, groups, top_k=top_k, show_progress_bar=show_progress_bar
558-
)
559-
for min_score in min_score_values:
560-
for name, fn in aggregators.items():
561-
row = evaluate_groups(
562-
scored,
563-
fn,
564-
aggregator_name=name,
565-
min_score_to_consider=min_score,
566-
top_n=top_n,
567-
decision_threshold=decision_threshold,
614+
configuration = 0
615+
for n_positive in positive_options:
616+
for ratio in ratio_options:
617+
configuration += 1
618+
619+
if n_positive is None and ratio is None:
620+
# Never call subsample() in the default case, so this keeps working
621+
# with any index-like object and stays byte-identical to before.
622+
working_index = index
623+
else:
624+
working_index = index.subsample(
625+
n_positive=n_positive,
626+
neg_to_pos_ratio=ratio,
627+
seed=index_seed,
628+
)
629+
630+
n_positive_actual = _row_count(
631+
getattr(working_index, "positive_embeddings", None)
632+
)
633+
n_negative_actual = _row_count(
634+
getattr(working_index, "negative_embeddings", None)
635+
)
636+
637+
if total_configurations > 1:
638+
# A long sweep is otherwise indistinguishable from a hang.
639+
LOG.info(
640+
"Grid search index configuration %d/%d: n_positive=%s ratio=%s "
641+
"(actual %s positives / %s negatives)",
642+
configuration,
643+
total_configurations,
644+
n_positive,
645+
ratio,
646+
n_positive_actual,
647+
n_negative_actual,
648+
)
649+
650+
for top_k in top_k_values:
651+
scored = score_groups(
652+
working_index,
653+
groups,
654+
top_k=top_k,
655+
show_progress_bar=show_progress_bar,
568656
)
569-
row["top_k"] = int(top_k)
570-
rows.append(row)
657+
for min_score in min_score_values:
658+
for name, fn in aggregators.items():
659+
row = evaluate_groups(
660+
scored,
661+
fn,
662+
aggregator_name=name,
663+
min_score_to_consider=min_score,
664+
top_n=top_n,
665+
decision_threshold=decision_threshold,
666+
)
667+
row["top_k"] = int(top_k)
668+
row["n_positive"] = n_positive
669+
row["neg_to_pos_ratio"] = ratio
670+
row["n_positive_actual"] = n_positive_actual
671+
row["n_negative_actual"] = n_negative_actual
672+
rows.append(row)
571673

572674
return rows

tests/test_simulation.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
that mimics ``calculate_rare_class_affinity``.
2020
"""
2121

22+
import logging
2223
from types import SimpleNamespace
2324

2425
import numpy as np
@@ -259,3 +260,135 @@ def test_run_grid_search_rescoring_and_rows():
259260
# Re-scoring happens once per top_k value (each scores both groups), and NOT
260261
# again for every threshold/aggregator combination: 2 top_k x 2 groups = 4.
261262
assert len(index.calls) == 2 * len(groups)
263+
264+
265+
class _StubSubsamplableIndex(_StubIndex):
266+
"""Stub that also records subsample() calls and reports embedding row counts."""
267+
268+
def __init__(self, n_positive=100, n_negative=500):
269+
super().__init__()
270+
self.subsample_calls = []
271+
self.positive_embeddings = np.zeros((n_positive, 4))
272+
self.negative_embeddings = np.zeros((n_negative, 4))
273+
274+
def subsample(self, n_positive=None, neg_to_pos_ratio=None, seed=None):
275+
self.subsample_calls.append(
276+
{"n_positive": n_positive, "neg_to_pos_ratio": neg_to_pos_ratio, "seed": seed}
277+
)
278+
available_positive = self.positive_embeddings.shape[0]
279+
kept_positive = (
280+
min(n_positive, available_positive) if n_positive else available_positive
281+
)
282+
kept_negative = (
283+
min(int(kept_positive * neg_to_pos_ratio), self.negative_embeddings.shape[0])
284+
if neg_to_pos_ratio
285+
else self.negative_embeddings.shape[0]
286+
)
287+
smaller = _StubSubsamplableIndex(kept_positive, kept_negative)
288+
# Share the call log so assertions can see scoring done via the copy.
289+
smaller.calls = self.calls
290+
return smaller
291+
292+
293+
def _two_groups():
294+
return [
295+
LabeledGroup(name="pos", label=1, observations=["0.9", "0.8"]),
296+
LabeledGroup(name="neg", label=0, observations=["0.2", "0.1"]),
297+
]
298+
299+
300+
def test_grid_search_without_index_axes_never_subsamples():
301+
"""The default path must not touch the index at all.
302+
303+
This is the backward-compatibility guarantee: callers passing any index-like
304+
object keep working, and behaviour is unchanged from before these axes existed.
305+
"""
306+
index = _StubSubsamplableIndex()
307+
rows = run_grid_search(index, _two_groups(), top_k_values=[3], min_score_values=[0.1])
308+
309+
assert index.subsample_calls == []
310+
assert len(rows) == len(DEFAULT_AGGREGATORS)
311+
# The new columns are still present, so the table shape is consistent.
312+
assert all(row["n_positive"] is None for row in rows)
313+
assert all(row["neg_to_pos_ratio"] is None for row in rows)
314+
315+
316+
def test_grid_search_sweeps_index_size_and_ratio():
317+
"""Both new axes multiply out, and each configuration is subsampled once."""
318+
index = _StubSubsamplableIndex()
319+
rows = run_grid_search(
320+
index,
321+
_two_groups(),
322+
top_k_values=[3, 5],
323+
min_score_values=[0.0],
324+
n_positive_values=[10, 20],
325+
neg_to_pos_ratios=[1.0, 2.0],
326+
index_seed=42,
327+
)
328+
329+
# 2 sizes x 2 ratios x 2 top_k x 1 threshold x 6 aggregators.
330+
assert len(rows) == 2 * 2 * 2 * len(DEFAULT_AGGREGATORS)
331+
# subsample() is called once per (size, ratio) pair, NOT once per top_k: the
332+
# whole point is that reshaping the index is cheap and re-scoring is not.
333+
assert len(index.subsample_calls) == 4
334+
assert all(call["seed"] == 42 for call in index.subsample_calls)
335+
assert {(c["n_positive"], c["neg_to_pos_ratio"]) for c in index.subsample_calls} == {
336+
(10, 1.0), (10, 2.0), (20, 1.0), (20, 2.0)
337+
}
338+
# Scoring happens once per (size, ratio, top_k), each covering both groups.
339+
assert len(index.calls) == 4 * 2 * len(_two_groups())
340+
341+
342+
def test_grid_search_reports_requested_and_actual_counts():
343+
"""Actual counts are emitted, because a request is clipped on a small index.
344+
345+
Without them, two rows can look identical while describing different indices.
346+
"""
347+
index = _StubSubsamplableIndex(n_positive=15, n_negative=500)
348+
rows = run_grid_search(
349+
index,
350+
_two_groups(),
351+
top_k_values=[3],
352+
min_score_values=[0.0],
353+
n_positive_values=[10, 999], # 999 exceeds the 15 available
354+
neg_to_pos_ratios=[2.0],
355+
)
356+
357+
by_request = {row["n_positive"]: row for row in rows}
358+
assert by_request[10]["n_positive_actual"] == 10
359+
assert by_request[10]["n_negative_actual"] == 20
360+
# Clipped to what the index actually holds, and visible in the row.
361+
assert by_request[999]["n_positive_actual"] == 15
362+
assert by_request[999]["n_negative_actual"] == 30
363+
364+
365+
def test_grid_search_one_axis_at_a_time():
366+
"""Either axis can be swept on its own."""
367+
index = _StubSubsamplableIndex()
368+
size_only = run_grid_search(
369+
index, _two_groups(), top_k_values=[3], min_score_values=[0.0],
370+
n_positive_values=[10, 20],
371+
)
372+
assert {row["n_positive"] for row in size_only} == {10, 20}
373+
assert all(row["neg_to_pos_ratio"] is None for row in size_only)
374+
375+
index2 = _StubSubsamplableIndex()
376+
ratio_only = run_grid_search(
377+
index2, _two_groups(), top_k_values=[3], min_score_values=[0.0],
378+
neg_to_pos_ratios=[0.5, 1.0],
379+
)
380+
assert {row["neg_to_pos_ratio"] for row in ratio_only} == {0.5, 1.0}
381+
assert all(row["n_positive"] is None for row in ratio_only)
382+
383+
384+
def test_grid_search_logs_progress_per_configuration(caplog):
385+
"""A long sweep logs progress so it cannot be mistaken for a hang."""
386+
caplog.set_level(logging.INFO)
387+
index = _StubSubsamplableIndex()
388+
run_grid_search(
389+
index, _two_groups(), top_k_values=[3], min_score_values=[0.0],
390+
n_positive_values=[10, 20], neg_to_pos_ratios=[1.0],
391+
)
392+
393+
assert "index configuration 1/2" in caplog.text
394+
assert "index configuration 2/2" in caplog.text

0 commit comments

Comments
 (0)