Add numpy-only simulation/tuning harness for comparing aggregators - #31
Conversation
Add sentinel.simulation with score_groups, evaluate_groups, compare_aggregators, and run_grid_search. evaluate_groups reports three evaluation-metric families (ranking, threshold/classification, separation/distribution) so users can tune the summarize metric and hyperparameters for their use case, not just ranking. - New module + public API exports; docs regenerated (docs-sync passes) - tests/test_simulation.py covers all three metric families and score_groups plumbing - Demonstrate in sentinel_against_hate.ipynb (compare aggregators + grid search) - README: 'Simulation-based tuning' section - Cleanups: fix Example_Threshold_Script.py (cache_model kwarg + index path), rename test_sriracha_local_index.py -> test_sentinel_local_index.py Co-authored-by: Cursor <cursoragent@cursor.com>
The tuning section previously shipped as code with no saved output, so a reader could not see what the harness actually concludes. Re-executed the notebook against the full dataset (30 controversial vs 30 Lex Fridman episodes, 28,956 segments) and committed the real tables and charts. Result: skewness is confirmed as the best aggregator, but the default min_score_to_consider=0.1 is not optimal on this data - the top four grid configurations are all skewness, led by top_k=5 with min_score_to_consider=0.0 at 0.996 ROC-AUC. - Explain why the demo subsamples to 30 controversial episodes: it matches the 30 Lex Fridman episodes so the evaluation set is balanced, which changes what top_n, precision and f1 mean. - Fix the neutral-data download cell, which failed with "poetry: command not found" because a %%bash cell starts a non-login shell that never picks up ~/.local/bin. It now reuses sys.executable, which is already the Poetry venv. - Strip tqdm progress-bar widget outputs and absolute local paths from the saved outputs. Co-authored-by: Cursor <cursoragent@cursor.com>
…esults Two sampling steps were unseeded and the transcript list came back in filesystem order, so every run scored a different set of episodes and produced different numbers. Seed both samples and sort the glob so a re-run is comparable to the one recorded here. Add a "What we found" section interpreting the tuning results. It is written qualitatively on purpose: with 30 episodes per class, differences of a few thousandths of ROC-AUC are inside the margin of error, and separate runs did reorder the leading aggregators. The claims that survive are that the top configurations are all skewness, and that min_score_to_consider matters more for skewness than for the others because skewness is computed over the whole score array while the other five filter to scores > 0. Note: the saved outputs predate the seed, so re-running will produce slightly different figures until the notebook is executed again. Co-authored-by: Cursor <cursoragent@cursor.com>
…l head Sorting the transcript glob made the selection reproducible but biased it badly. The corpus holds three shows, so transcript_files[:100] took all 16 ajn-live episodes plus the first 84 alex-jones-show ones and never reached special-reports at all. Those episodes are also far shorter, which starves the aggregators of the extreme segments they rely on: the controversial episodes scored above zero on 1.4% of segments versus 1.2% for Lex Fridman, leaving almost nothing to separate, and ROC-AUC fell to 0.70. Draw a seeded random sample over the sorted pool instead, which is both deterministic and representative (86 alex-jones-show / 14 special-reports, matching the corpus). Segment-level separation returns to 1.9% versus 1.0%. Re-ran the notebook on that sample. Untuned, the aggregators cluster between 0.80 and 0.89 ROC-AUC with skewness ahead at 0.894; the grid search lifts skewness with top_k=5 and min_score_to_consider=0.0 to 0.977, and the top three configurations are all skewness. Cross-checked that compare_aggregators and run_grid_search agree exactly where they overlap. Co-authored-by: Cursor <cursoragent@cursor.com>
Stack mapThis PR is the base of a four-PR stack implementing the improvement plan. Each PR targets the one below it, so each diff shows only its own changes. Suggested review and merge order: #31 → #32 → #33 → #34, with #35 mergeable any time after #32. #32 goes first among the fixes because there is no point tuning parameters while loading is non-deterministic — you cannot tell a real improvement from random variation in which negatives happened to survive. It is also the easiest to approve, since all three of its parts are defensibly bug fixes rather than features. #35 deliberately branches off #32 rather than sitting on top of the stack: its only real dependency is the
Verified the stack combines cleanly: merging #35 into the tip of #34 produces no conflicts, and the combined tree passes all 99 tests with a clean flake8. CI note: |
| observation_scores = np.asarray( | ||
| list(result.observation_scores.values()), dtype=float | ||
| ) |
There was a problem hiding this comment.
Not a new change from this PR, but an observation: If there are repeated/duplicate messages, it seems calculate_rare_class_affinity dedupes when it builds a dict of text --> score. We could do something like
scores_by_text = result.observation_scores
observation_scores = np.asarray(
[scores_by_text[text] for text in observations], dtype=float
)
to restore the full-length array.
There was a problem hiding this comment.
This correction makes sense. Will fix with a follow up PR.
| in_top = labels[leaderboard_rank <= top_n_eff] | ||
| tp_in_top = int(np.sum(in_top == 1)) | ||
| metrics["precision_at_n"] = tp_in_top / top_n_eff | ||
| metrics["recall_at_n"] = tp_in_top / n_pos |
There was a problem hiding this comment.
Edge case: with _average_ranks, ties are averaged, so it may be possible for precision_at_n > 1. For example, five positives tied at the top, the rank for all 5 will be 3. With top_n_eff=3, tp_in_top would be 5?
There was a problem hiding this comment.
Will address in follow up PR as well.
vcai4071
left a comment
There was a problem hiding this comment.
LGTM! A couple of minor comments.
Summary
Picking an aggregator (and its hyperparameters) is data-dependent, but there was no lightweight way to measure that choice against labeled examples. This PR adds
sentinel.simulation, a numpy-only harness for tuning — no Ray, S3, or experiment trackers required.src/sentinel/simulation.pyexposingLabeledGroup,score_groups,evaluate_groups,compare_aggregators, andrun_grid_search, all re-exported from the public API insrc/sentinel/__init__.py.score_groupsruns the expensive model step a single time;compare_aggregatorsandrun_grid_searchthen sweep all six aggregators and hyperparameter combinations over those cached scores. Sweepingmin_score_to_consideris free because_apply_thresholdre-applies the exact rulecalculate_rare_class_affinityuses, on already-computed scores.roc_auc,recall_at_n,precision_at_n,rank_ratioprecision,recall,f1,false_positive_rateat a chosen or automatically best-F1 cutoffmean_separation,cohens_d,ks_statistic(threshold-free)Results on real data
The notebook section runs end-to-end on 30 controversial vs 30 Lex Fridman episodes and its outputs are committed, so reviewers see the actual conclusion rather than just the code.
With the library defaults (
top_k=5,min_score_to_consider=0.1) the aggregators are bunched together and the choice looks unimportant:The grid search is where it pays off:
Two conclusions. Sentinel's default aggregator is the right one — the top three configurations are all
skewness. But the default noise floor ofmin_score_to_consider=0.1is costing accuracy on this dataset; dropping it to0.0takesskewnessfrom 0.894 to 0.977. That is exactly the kind of finding the harness exists to surface, and it is not visible without sweeping.Also included
README.md: new "Simulation-based tuning" section with a worked snippet.examples/sentinel_against_hate.ipynb: the new tuning section (comparison table, per-family bar charts, grid search, ROC-AUC heatmap) plus a "What we found" write-up.random.samplerather than an alphabetical slice — the corpus is grouped by show, so[:100]would have covered only the shows sorting first and skippedspecial-reportsentirely.poetry: command not found: a%%bashcell starts a non-login shell that never picks up~/.local/bin. It now reusessys.executable, which is already the Poetry venv.docs/generate_docs.py(modules.rst,sentinel.rst).examples/Example_Threshold_Script.py(cache_modelkwarg and index path), and renamedtests/test_sriracha_local_index.pytotests/test_sentinel_local_index.pyto match the project name.Notes for reviewers
compare_aggregatorsandrun_grid_searchagree exactly where they overlap (skewnessattop_k=5, min_score=0.1is 0.894444 in both), confirming the cheap post-hoc thresholding matches the full re-scoring path.Test plan
pytest tests/test_simulation.py— 14 new tests covering all three metric families andscore_groupsplumbingpytest tests/— full suite passes (54 tests)python docs/generate_docs.pyleavesdocs/source/clean, so the CI docs-sync check passesnbformat.validatepasses, no cell contains an error output