edgepoint 3.0.0__tar.gz → 3.0.2__tar.gz

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.
Files changed (28) hide show
  1. {edgepoint-3.0.0 → edgepoint-3.0.2}/PKG-INFO +1 -1
  2. {edgepoint-3.0.0 → edgepoint-3.0.2}/edgepoint/main.py +59 -39
  3. edgepoint-3.0.2/edgepoint/run_kfold_search.py +192 -0
  4. {edgepoint-3.0.0 → edgepoint-3.0.2}/edgepoint/sanity_check.py +1 -1
  5. {edgepoint-3.0.0 → edgepoint-3.0.2}/edgepoint/train_combo.py +134 -43
  6. {edgepoint-3.0.0 → edgepoint-3.0.2}/edgepoint/validations.py +2 -21
  7. {edgepoint-3.0.0 → edgepoint-3.0.2}/edgepoint.egg-info/PKG-INFO +1 -1
  8. {edgepoint-3.0.0 → edgepoint-3.0.2}/pyproject.toml +1 -1
  9. {edgepoint-3.0.0 → edgepoint-3.0.2}/setup.py +1 -1
  10. edgepoint-3.0.0/edgepoint/run_kfold_search.py +0 -251
  11. {edgepoint-3.0.0 → edgepoint-3.0.2}/LICENSE +0 -0
  12. {edgepoint-3.0.0 → edgepoint-3.0.2}/README.md +0 -0
  13. {edgepoint-3.0.0 → edgepoint-3.0.2}/edgepoint/__init__.py +0 -0
  14. {edgepoint-3.0.0 → edgepoint-3.0.2}/edgepoint/aggregate_kfold_combos.py +0 -0
  15. {edgepoint-3.0.0 → edgepoint-3.0.2}/edgepoint/aggregate_kfold_edges.py +0 -0
  16. {edgepoint-3.0.0 → edgepoint-3.0.2}/edgepoint/core.py +0 -0
  17. {edgepoint-3.0.0 → edgepoint-3.0.2}/edgepoint/core_v1.py +0 -0
  18. {edgepoint-3.0.0 → edgepoint-3.0.2}/edgepoint/main_old.py +0 -0
  19. {edgepoint-3.0.0 → edgepoint-3.0.2}/edgepoint/run_kfold_find.py +0 -0
  20. {edgepoint-3.0.0 → edgepoint-3.0.2}/edgepoint/split_router.py +0 -0
  21. {edgepoint-3.0.0 → edgepoint-3.0.2}/edgepoint/stratified_folds.py +0 -0
  22. {edgepoint-3.0.0 → edgepoint-3.0.2}/edgepoint/test_and_combo_val.py +0 -0
  23. {edgepoint-3.0.0 → edgepoint-3.0.2}/edgepoint/utils.py +0 -0
  24. {edgepoint-3.0.0 → edgepoint-3.0.2}/edgepoint.egg-info/SOURCES.txt +0 -0
  25. {edgepoint-3.0.0 → edgepoint-3.0.2}/edgepoint.egg-info/dependency_links.txt +0 -0
  26. {edgepoint-3.0.0 → edgepoint-3.0.2}/edgepoint.egg-info/requires.txt +0 -0
  27. {edgepoint-3.0.0 → edgepoint-3.0.2}/edgepoint.egg-info/top_level.txt +0 -0
  28. {edgepoint-3.0.0 → edgepoint-3.0.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: edgepoint
3
- Version: 3.0.0
3
+ Version: 3.0.2
4
4
  Summary: Find the point in a numeric column above which a binary outcome becomes meaningfully and reliably better.
5
5
  Author: Henry
6
6
  License: MIT
@@ -17,14 +17,6 @@ def _print_table(display_df):
17
17
  print(display_df)
18
18
 
19
19
 
20
- # Display metadata for the 3 combo-search modes - same dots/labels/hints
21
- # the old script's banner used, so the mode indicator here matches.
22
- _MODE_INFO = {
23
- "quick": {"dot": "\U0001F7E2", "label": "QUICK", "hint": "< 3 mins"},
24
- "standard": {"dot": "\U0001F7E1", "label": "STANDARD", "hint": "> 3 mins"},
25
- "deep": {"dot": "\U0001F534", "label": "DEEP", "hint": "> 10 mins"},
26
- }
27
-
28
20
  STAGE_PAUSE_SECONDS = 0.6 # cosmetic pause between stages, same as old script
29
21
  TOTAL_STAGES = 6
30
22
 
@@ -43,16 +35,11 @@ def _format_duration(seconds):
43
35
  return f"{mins}mins {secs} secs"
44
36
 
45
37
 
46
- def _banner(mode):
47
- # Printed once, before any stage - app title + active mode only.
48
- # No timestamp here - that's added after the dataset summary instead,
49
- # same as the other stages get their own timestamp.
50
- print(_bold("EDGEPOINT."))
38
+ def _banner():
39
+ # Printed once, before any stage - app title + begin timestamp on
40
+ # one line, followed by the divider.
41
+ print(_bold(f"\nEDGEPOINT. \U0001F552 Begin : {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}."))
51
42
  print("-" * 32)
52
- for key, info in _MODE_INFO.items():
53
- marker = "->" if key == mode else " "
54
- print(f" {marker} {info['dot']} {info['label']:<9} {info['hint']:<8}"
55
- f"{' (active)' if key == mode else ''}")
56
43
  time.sleep(STAGE_PAUSE_SECONDS)
57
44
 
58
45
 
@@ -173,12 +160,16 @@ def _print_legend(legend):
173
160
  print(f" {abbr:<{abbr_width}} = {full}")
174
161
 
175
162
 
176
- def _print_top_combos(merged_combos_df, top_combo_n=3):
163
+ def _print_top_combos(merged_combos_df, baseline_full, top_combo_n=3):
177
164
  """
178
165
  Prints the top `top_combo_n` combos (already sorted by final_score)
179
166
  side by side as columns - one column per combo, edges and stats as
180
- rows. Pure content only - the banner and stage timing live in
181
- search(), same as train_and_combo_gen/test_and_combo_val are
167
+ rows. Also appends a Baseline row (the dataset's overall "do
168
+ nothing" hit rate) and a vs Baseline row (the worse of that combo's
169
+ train/test hit_rate minus baseline, signed +/-) so it's obvious at
170
+ a glance whether a combo actually beats doing nothing even in its
171
+ weaker split. Pure content only - the banner and stage timing live
172
+ in search(), same as train_and_combo_gen/test_and_combo_val are
182
173
  the actual stages, not this print function.
183
174
  """
184
175
  top_df = merged_combos_df.head(top_combo_n)
@@ -206,8 +197,10 @@ def _print_top_combos(merged_combos_df, top_combo_n=3):
206
197
  ]
207
198
  stat_fields = [(k, l) for k, l in stat_fields if k in top_df.columns]
208
199
 
200
+ extra_labels = ["Baseline", "vs Baseline"]
209
201
  label_width = max(
210
- [len(c) for c in edge_cols] + [len(l) for _, l in stat_fields] + [5]
202
+ [len(c) for c in edge_cols] + [len(l) for _, l in stat_fields]
203
+ + [len(l) for l in extra_labels] + [5]
211
204
  )
212
205
  col_width = max(14, label_width)
213
206
 
@@ -234,6 +227,23 @@ def _print_top_combos(merged_combos_df, top_combo_n=3):
234
227
  values = [row[key] for row in rows]
235
228
  print(fmt_row(label, values))
236
229
 
230
+ if baseline_full is not None:
231
+ baseline_values = [f"{baseline_full:.2f}" for _ in rows]
232
+ print(fmt_row("Baseline", baseline_values))
233
+
234
+ vs_baseline_values = []
235
+ for row in rows:
236
+ raw_candidates = [row.get("hit_rate_tr"), row.get("hit_rate_te")]
237
+ candidates = [float(v) for v in raw_candidates if v is not None]
238
+ if candidates:
239
+ worst = min(candidates)
240
+ diff = worst - baseline_full
241
+ sign = "+" if diff >= 0 else "-"
242
+ vs_baseline_values.append(f"{abs(diff):.2f} {sign}")
243
+ else:
244
+ vs_baseline_values.append("n/a")
245
+ print(fmt_row("vs Baseline", vs_baseline_values, bold_values=True))
246
+
237
247
 
238
248
  def _fmt_pct(v):
239
249
  return f"{v:.2f}" if v is not None else "n/a"
@@ -325,7 +335,7 @@ def _add_final_score(merged_df):
325
335
  return merged_df
326
336
 
327
337
 
328
- def _row_to_combo_dict(row, top_combos_df):
338
+ def _row_to_combo_dict(row, top_combos_df, baseline_full):
329
339
  """
330
340
  Builds one combo's return-value dict from a merged_combos_df row.
331
341
  Mirrors main.py's _row_to_combo_dict: edges are looked up from
@@ -333,17 +343,27 @@ def _row_to_combo_dict(row, top_combos_df):
333
343
  main.py looks edges up from its own top_combos_df) by combo_num -
334
344
  merged_combos_df itself never carries an 'edges' column, it only
335
345
  carries the _tr/_te stat columns produced by the Stage 5 merge.
346
+ baseline_full/vs_baseline mirror the same fields shown in the
347
+ printed top-combos summary (Stage 6): vs_baseline is the worse of
348
+ this combo's train/test hit_rate minus baseline_full, so a caller
349
+ can tell at a glance whether the weaker split still beats "do
350
+ nothing" without recomputing it themselves.
336
351
  """
337
352
  combo_num = row["combo_num"]
338
353
  edges_row = top_combos_df[top_combos_df["combo_num"] == combo_num]
339
354
  edges = edges_row.iloc[0]["edges"] if not edges_row.empty else {}
340
355
 
356
+ hit_rate_tr = row.get("hit_rate_tr")
357
+ hit_rate_te = row.get("hit_rate_te")
358
+ candidates = [float(v) for v in (hit_rate_tr, hit_rate_te) if v is not None]
359
+ vs_baseline = (min(candidates) - baseline_full) if candidates and baseline_full is not None else None
360
+
341
361
  return {
342
362
  "combo_num": int(combo_num),
343
363
  "combo": row["combo"],
344
364
  "edges": dict(edges),
345
- "hit_rate_tr": row.get("hit_rate_tr"),
346
- "hit_rate_te": row.get("hit_rate_te"),
365
+ "hit_rate_tr": hit_rate_tr,
366
+ "hit_rate_te": hit_rate_te,
347
367
  "coverage_tr": row.get("coverage_tr"),
348
368
  "coverage_te": row.get("coverage_te"),
349
369
  "n_tr": row.get("n_tr"),
@@ -351,6 +371,8 @@ def _row_to_combo_dict(row, top_combos_df):
351
371
  "score_tr": row.get("score_tr"),
352
372
  "score_te": row.get("score_te"),
353
373
  "final_score": row.get("final_score"),
374
+ "baseline_full": round(baseline_full, 2) if baseline_full is not None else None,
375
+ "vs_baseline": round(vs_baseline, 2) if vs_baseline is not None else None,
354
376
  }
355
377
 
356
378
 
@@ -368,10 +390,12 @@ def _empty_combo_dict():
368
390
  "score_tr": None,
369
391
  "score_te": None,
370
392
  "final_score": None,
393
+ "baseline_full": None,
394
+ "vs_baseline": None,
371
395
  }
372
396
 
373
397
 
374
- def _build_top_combos(merged_combos_df, top_combos_df, top_combo_n):
398
+ def _build_top_combos(merged_combos_df, top_combos_df, top_combo_n, baseline_full):
375
399
  """
376
400
  Same shape/behavior as main.py's _build_top_combos: top `top_combo_n`
377
401
  rows from merged_combos_df (already sorted by final_score). Returns
@@ -383,27 +407,23 @@ def _build_top_combos(merged_combos_df, top_combos_df, top_combo_n):
383
407
  return _empty_combo_dict()
384
408
 
385
409
  rows = [row for _, row in merged_combos_df.head(top_combo_n).iterrows()]
386
- combos = [_row_to_combo_dict(row, top_combos_df) for row in rows]
410
+ combos = [_row_to_combo_dict(row, top_combos_df, baseline_full) for row in rows]
387
411
 
388
412
  if top_combo_n == 1:
389
413
  return combos[0]
390
414
  return combos
391
415
 
392
416
 
393
- def search(df, outcome_col="hit", mode="quick", min_coverage=10, show_progress=True, top_combo_n=3):
417
+ def search(df, outcome_col="hit", min_coverage=10, show_progress=True, top_combo_n=3):
394
418
  # Validate every param up front - before the banner, split, core.find,
395
- # or any other side-effecting work runs. mode is reassigned to the
396
- # normalized (stripped/lowercased) string validate_search_params
397
- # returns, so every downstream use of `mode` (banner, stage 2, etc.)
398
- # sees the exact same cleaned-up spelling.
399
- mode = validate_search_params(
400
- df, outcome_col, mode, min_coverage, show_progress, top_combo_n,
419
+ # or any other side-effecting work runs.
420
+ validate_search_params(
421
+ df, outcome_col, min_coverage, show_progress, top_combo_n,
401
422
  )
402
423
 
403
424
  run_start = time.time()
404
425
  if show_progress:
405
- _banner(mode)
406
- print(f"\n \U0001F552 Begin : {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}.")
426
+ _banner()
407
427
 
408
428
  # ---- Stage 1: Data Sanity / Splitting ---------------------------------
409
429
  # Chronological split, no shuffling: oldest rows -> train, newest rows
@@ -438,7 +458,7 @@ def search(df, outcome_col="hit", mode="quick", min_coverage=10, show_progress=T
438
458
  if show_progress:
439
459
  _stage_header(2, TOTAL_STAGES, "Train Section (train_and_combo_gen)")
440
460
  train_results_df, folds_df, top_combos_df = train_and_combo_gen(
441
- train_df, outcome_col=outcome_col, mode=mode, min_coverage=min_coverage,
461
+ train_df, outcome_col=outcome_col, min_coverage=min_coverage,
442
462
  show_progress=show_progress,
443
463
  )
444
464
  if show_progress:
@@ -515,13 +535,13 @@ def search(df, outcome_col="hit", mode="quick", min_coverage=10, show_progress=T
515
535
  # ---- Build top_combos (mirrors main.py's _build_top_combos) -----------
516
536
  # Same shape as main.py: top_combo_n==1 -> single dict, else a list of
517
537
  # dicts, each combo's edges looked up from top_combos_df by combo_num.
518
- top_combos = _build_top_combos(merged_combos_df, top_combos_df, top_combo_n)
538
+ top_combos = _build_top_combos(merged_combos_df, top_combos_df, top_combo_n, baseline_full)
519
539
 
520
540
  # ---- Stage 6: Top Combos Summary ---------------------------------------
521
541
  stage_start = time.time()
522
542
  if show_progress:
523
543
  _stage_header(6, TOTAL_STAGES, f"Top {top_combo_n} \u2764\ufe0f Combo(s) (train vs test)")
524
- _print_top_combos(merged_combos_df, top_combo_n)
544
+ _print_top_combos(merged_combos_df, baseline_full, top_combo_n)
525
545
  _stage_footer(stage_start, run_start)
526
546
  _complete(run_start)
527
547
 
@@ -539,7 +559,7 @@ if __name__ == "__main__":
539
559
  # Split ratio is auto-detected from row count inside search()'s
540
560
  # Stage 1 (via split_router.split_dataset). Nothing else changes.
541
561
  edgepoints_df, top_combos = search(
542
- df, outcome_col="hit", mode="quick", min_coverage=10, show_progress=True,
562
+ df, outcome_col="hit", min_coverage=10, show_progress=True,
543
563
  top_combo_n=3,
544
564
  )
545
565
  """
@@ -0,0 +1,192 @@
1
+ import pandas as pd
2
+ from datetime import datetime
3
+ from .run_kfold_find import run_kfold_find
4
+ from .train_combo import recommend
5
+ from .aggregate_kfold_combos import aggregate_kfold_combos
6
+ from .utils import (
7
+ GAP_WEIGHT,
8
+ SHRINKAGE_K,
9
+ )
10
+ from .utils import _score_combos_on_test
11
+
12
+ def _baseline_hit_rate(df, outcome_col):
13
+ """mean(outcome_col)*100 with no threshold filter - the "do nothing" baseline."""
14
+ y = df[outcome_col].dropna()
15
+ if len(y) == 0:
16
+ return None
17
+ return float(y.astype(int).mean()) * 100.0
18
+
19
+ def _print_table(display_df):
20
+ # Consistent print formatting for every table in this module - same
21
+ # option_context recipe used in aggregate_kfold_combos.py.
22
+ with pd.option_context(
23
+ "display.width", 1000, "display.max_columns", None,
24
+ "display.max_colwidth", None, "display.expand_frame_repr", False,
25
+ ):
26
+ print(display_df)
27
+
28
+
29
+ def train_and_combo_gen(
30
+ df,
31
+ outcome_col,
32
+ columns=None,
33
+ min_coverage=20,
34
+ show_progress=True,
35
+ debug=False,
36
+ ):
37
+ # debug=True always turns on show_progress - debug tables only ever
38
+ # print inside the show_progress-gated blocks below.
39
+ if debug:
40
+ show_progress = True
41
+
42
+ train_results_df, folds_df = run_kfold_find(
43
+ df,
44
+ outcome_col,
45
+ columns=columns,
46
+ min_coverage=min_coverage,
47
+ gap_weight=GAP_WEIGHT,
48
+ shrinkage_k=SHRINKAGE_K,
49
+ show_progress=show_progress,
50
+ )
51
+
52
+ # GLOBAL qualify bar = the overall positive rate on the FULL df,
53
+ # computed before any folding/splitting happens. Fixed, absolute:
54
+ # a combo that can't beat "doing nothing" is disqualified, no matter
55
+ # how it compares to other combos. Reused as-is in every per-fold
56
+ # gate below and passed into aggregate_kfold_combos - never
57
+ # recomputed, so the bar never drifts anywhere in this pipeline.
58
+ baseline_full = _baseline_hit_rate(df, outcome_col)
59
+
60
+ if show_progress and baseline_full is not None:
61
+ print(f"\n[debug] global qualify bar (overall positive rate): {baseline_full:.2f}")
62
+
63
+ full_combos_df = recommend(
64
+ folds_df[0],
65
+ train_results_df,
66
+ outcome_col=outcome_col,
67
+ min_coverage=min_coverage,
68
+ gap_weight=GAP_WEIGHT,
69
+ shrinkage_k=SHRINKAGE_K,
70
+ show_progress=show_progress,
71
+ debug= False,
72
+ )
73
+
74
+ if show_progress:
75
+ display_df = full_combos_df[["combo_num", "combo", "hit_rate", "coverage", "n", "score"]].copy()
76
+ display_df = display_df if debug else display_df.head(5)
77
+ print(f"=== FOLD 1 VALIDATION ({len(full_combos_df)} combo(s) survived) ===")
78
+ _print_table(display_df)
79
+
80
+ # surviving_combos_df narrows every round - only currently-surviving
81
+ # combos get tested against the next fold. By loop end it holds only
82
+ # the combos that made it through every fold - the set to aggregate.
83
+ surviving_combos_df = full_combos_df
84
+ combo_fold_results = []
85
+ for i, fold in enumerate(folds_df[1:], start=2):
86
+ if surviving_combos_df.empty:
87
+ break
88
+
89
+ going_in = len(surviving_combos_df)
90
+ fold_combos_df = _score_combos_on_test(
91
+ fold, outcome_col, surviving_combos_df, min_coverage, GAP_WEIGHT, SHRINKAGE_K
92
+ )
93
+
94
+ # Anything that went in but isn't in _score_combos_on_test's
95
+ # output failed min_coverage in this fold.
96
+ after_coverage = len(fold_combos_df)
97
+ dropped_coverage = going_in - after_coverage
98
+
99
+ # Per-fold qualify gate: _score_combos_on_test only filters by
100
+ # min_coverage, not the qualify bar. Without this, a combo could
101
+ # dip below baseline_full, still "survive" on coverage alone, and
102
+ # get diluted/hidden by good folds instead of disqualified
103
+ # outright. Uses the GLOBAL baseline_full (fixed on the full df,
104
+ # not this fold's own numbers) so the bar never drifts.
105
+ dropped_baseline = 0
106
+ if not fold_combos_df.empty and baseline_full is not None:
107
+ fold_hit_rates = fold_combos_df["hit_rate"].astype(float)
108
+ before_baseline = len(fold_combos_df)
109
+ fold_combos_df = fold_combos_df[fold_hit_rates >= baseline_full].reset_index(drop=True)
110
+ dropped_baseline = before_baseline - len(fold_combos_df)
111
+
112
+ if show_progress:
113
+ print(f"\nfold {i}: in={going_in} coverage_drop={dropped_coverage} "
114
+ f"baseline_drop={dropped_baseline} remain={len(fold_combos_df)}")
115
+
116
+ combo_fold_results.append(fold_combos_df)
117
+
118
+ if show_progress:
119
+ # Built directly instead of via a shared formatting helper -
120
+ # avoids pulling in train_hit_rate/train_coverage columns
121
+ # that shouldn't show here.
122
+ display_df = fold_combos_df[["combo_num", "combo", "hit_rate", "coverage", "n"]].copy()
123
+ # _score_combos_on_test returns raw floats (unlike
124
+ # recommend()'s fold-1 output, already 2dp) - match that
125
+ # convention here too.
126
+ display_df["hit_rate"] = display_df["hit_rate"].map(lambda v: f"{float(v):.2f}")
127
+ display_df["coverage"] = display_df["coverage"].map(lambda v: f"{float(v):.2f}")
128
+ if "score" in fold_combos_df.columns:
129
+ display_df["score"] = fold_combos_df["score"]
130
+ display_df = display_df if debug else display_df.head(5)
131
+ print(f"\n=== FOLD {i} VALIDATION ({len(fold_combos_df)} combo(s) survived) ===")
132
+ _print_table(display_df)
133
+
134
+ if fold_combos_df.empty:
135
+ surviving_combos_df = fold_combos_df
136
+ break
137
+
138
+ surviving_nums = set(fold_combos_df["combo_num"])
139
+ surviving_combos_df = full_combos_df[full_combos_df["combo_num"].isin(surviving_nums)].reset_index(drop=True)
140
+
141
+ # Restrict aggregation to only the FINAL survivors - combo_fold_results
142
+ # still contains early-fold entries for combos eliminated partway
143
+ # through, so filter both the lookup table and every per-fold
144
+ # snapshot down to final_surviving_nums before aggregating.
145
+ final_surviving_nums = set(surviving_combos_df["combo_num"]) if not surviving_combos_df.empty else set()
146
+
147
+ filtered_full_combos_df = full_combos_df[
148
+ full_combos_df["combo_num"].isin(final_surviving_nums)
149
+ ].reset_index(drop=True)
150
+
151
+ filtered_combo_fold_results = [
152
+ fold_df[fold_df["combo_num"].isin(final_surviving_nums)].reset_index(drop=True)
153
+ for fold_df in combo_fold_results
154
+ ]
155
+
156
+ if show_progress:
157
+ print(f"\n[debug] {len(final_surviving_nums)} combo(s) survived every fold - "
158
+ f"aggregating only these (out of {len(full_combos_df)} originally generated).")
159
+
160
+ final_fold = folds_df[-1]
161
+
162
+ # baseline_full is passed straight into aggregate_kfold_combos, which
163
+ # runs the same "hit_rate >= baseline_full" check internally on its
164
+ # own aggregated hit_rate before scoring/sorting/printing - so
165
+ # aggregated_combos_df arriving here is already baseline-filtered.
166
+ aggregated_combos_df = aggregate_kfold_combos(
167
+ df, outcome_col, filtered_full_combos_df, filtered_combo_fold_results,
168
+ gap_weight=GAP_WEIGHT, shrinkage_k=SHRINKAGE_K, show_progress=False,
169
+ baseline_full=baseline_full,
170
+ min_coverage=min_coverage,
171
+ )
172
+
173
+ # Final surviving combos, returned RAW from recommend(). No join, no
174
+ # recompute - filtered_full_combos_df is recommend()'s own untouched
175
+ # output, filtered down to combo_nums that survived every fold and
176
+ # the final baseline gate.
177
+ final_qualified_nums = set(aggregated_combos_df["combo_num"]) if not aggregated_combos_df.empty else set()
178
+ top_combos_df = filtered_full_combos_df[
179
+ filtered_full_combos_df["combo_num"].isin(final_qualified_nums)
180
+ ].reset_index(drop=True)
181
+
182
+ # Overwrite hit_rate with the AGGREGATED, baseline-gated number.
183
+ # filtered_full_combos_df's own hit_rate is recommend()'s raw
184
+ # fold[0]-only output, never gated against baseline_full - without
185
+ # this, top_combos_df (and train_hit_rate downstream) could show a
186
+ # value below baseline_full even though the combo only qualified via
187
+ # its aggregated hit_rate clearing the bar.
188
+ if not top_combos_df.empty and not aggregated_combos_df.empty:
189
+ agg_hit_rate_map = dict(zip(aggregated_combos_df["combo_num"], aggregated_combos_df["hit_rate"]))
190
+ top_combos_df["hit_rate"] = top_combos_df["combo_num"].map(agg_hit_rate_map)
191
+
192
+ return train_results_df, folds_df, top_combos_df
@@ -3,7 +3,7 @@ import pandas as pd
3
3
  # Minimum number of rows df must have for sanity_check_columns to run at
4
4
  # all - below this, there's not enough data for a threshold search to
5
5
  # mean anything, regardless of how many columns pass the numeric check.
6
- MIN_ROWS = 60
6
+ MIN_ROWS = 15
7
7
 
8
8
  # The only values outcome_col is allowed to contain - anything else
9
9
  # (NaN, strings, other numbers) gets dropped as an invalid outcome row.
@@ -1,9 +1,10 @@
1
1
  import math
2
2
  import time
3
-
3
+ from datetime import datetime
4
+
4
5
  import pandas as pd
5
6
  from itertools import combinations
6
-
7
+
7
8
  from .core import _normalize, _shrink_gap, DEFAULT_SHRINKAGE_K
8
9
 
9
10
  # CPU relief is always applied (not optional) - these just control how
@@ -11,14 +12,24 @@ from .core import _normalize, _shrink_gap, DEFAULT_SHRINKAGE_K
11
12
  RELIEF_EVERY = 200
12
13
  RELIEF_SECONDS = 0.001
13
14
 
14
- # Hard ceiling on generated combinations, used both to auto-fit
15
- # mode="deep" and to refuse mode="quick"/"standard" up front if they'd
16
- # blow past it.
15
+ # Hard ceiling on generated combinations - a pure memory/compute safety
16
+ # net, always applied. Never lets combo_generation blow past this many
17
+ # combos regardless of what row/baseline logic below decides.
17
18
  MAX_COMBOS = 2_000_000
18
19
 
19
-
20
- def _estimate_combo_count(n_columns, min_size, max_size):
21
- return sum(math.comb(n_columns, k) for k in range(min_size, max_size + 1))
20
+ # Row-count -> max combo size table. Direct and simple on purpose: more
21
+ # rows means more trust to combine columns, no per-combo ratio math
22
+ # involved. This only decides combo SIZE - see the baseline column drop
23
+ # further down, which decides which columns are even allowed to be
24
+ # combined at that size. Tune the thresholds here if these defaults
25
+ # don't fit your data.
26
+ ROW_CAP_TABLE = [
27
+ (50, 1), # under 50 rows -> singles only
28
+ (200, 2), # 50-200 rows -> pairs allowed
29
+ (500, 3), # 200-500 rows -> triples allowed
30
+ (2000, 4), # 500-2000 rows -> up to 4
31
+ ]
32
+ ROW_CAP_TABLE_MAX = 5 # 2000+ rows -> up to 5 (then memory budget takes over)
22
33
 
23
34
 
24
35
  def _auto_cap_from_budget(n_columns, max_combos, floor=1):
@@ -40,6 +51,33 @@ def _auto_cap_from_budget(n_columns, max_combos, floor=1):
40
51
  return max(floor, cap)
41
52
 
42
53
 
54
+ def _cap_from_rows(n_rows, floor=1):
55
+ # Statistical sibling of _auto_cap_from_budget: memory answers "what
56
+ # can I afford", this answers "what should I trust". A direct
57
+ # row-count -> cap lookup - the more rows you have, the bigger a
58
+ # combo you're allowed to build, full stop. No column count involved
59
+ # here on purpose; column count is handled separately (see the
60
+ # baseline-drop filter in recommend()) so the two concerns - combo
61
+ # SIZE vs. which columns are eligible to be combined - stay decoupled.
62
+ if n_rows <= 0:
63
+ return floor
64
+ for threshold, cap in ROW_CAP_TABLE:
65
+ if n_rows < threshold:
66
+ return max(floor, cap)
67
+ return max(floor, ROW_CAP_TABLE_MAX)
68
+
69
+
70
+ def _baseline_hit_rate(df, outcome_col):
71
+ # mean(outcome_col)*100 with no threshold filter - the "do nothing"
72
+ # baseline. Same definition/logic used in train_and_combo_gen.py's
73
+ # global qualify bar - reused here, not reinvented, so a column that
74
+ # can't beat "doing nothing" is treated the same way in both places.
75
+ y = df[outcome_col].dropna()
76
+ if len(y) == 0:
77
+ return None
78
+ return float(y.astype(int).mean()) * 100.0
79
+
80
+
43
81
  def combo_generation(columns, include_empty=False, max_size=None):
44
82
  min_size = 0 if include_empty else 1
45
83
  ceiling = len(columns) if max_size is None else min(max_size, len(columns))
@@ -59,11 +97,23 @@ def recommend(
59
97
  min_coverage=40,
60
98
  gap_weight=0.60,
61
99
  shrinkage_k=DEFAULT_SHRINKAGE_K,
62
- mode="standard",
63
100
  show_progress=False,
101
+ debug=False,
64
102
  ):
65
103
  # find() is no longer called here - the caller runs find() once
66
104
  # and passes in its two outputs (train_df, train_results_df) directly.
105
+ #
106
+ # show_progress vs debug: show_progress prints the high-level view -
107
+ # the single-column table, cap resolution, eligible/dropped columns,
108
+ # and the final combo summary. debug additionally prints the verbose
109
+ # per-combo loop (every combo's columns/edges and computed stats) -
110
+ # useful when troubleshooting a specific combo, but far too noisy to
111
+ # tie to show_progress alone once the combo count gets large.
112
+
113
+ # debug implies show_progress - the verbose per-combo view is only
114
+ # useful alongside the high-level progress view, not instead of it.
115
+ if debug:
116
+ show_progress = True
67
117
 
68
118
  considered_columns = [c for c in train_df.columns if c != outcome_col]
69
119
 
@@ -94,44 +144,85 @@ def recommend(
94
144
  def fmt_row(vals):
95
145
  return " ".join(str(v).ljust(w) for v, w in zip(vals, widths))
96
146
 
97
- if show_progress:
147
+ if debug:
98
148
  print("=== RECOMMEND ===")
99
149
  print(fmt_row(headers))
100
150
  print(" ".join("-" * w for w in widths))
101
151
  for row in print_rows:
102
152
  print(fmt_row(row))
103
153
 
154
+ # top_columns/edge_map stay as the FULL scored set - this is what's
155
+ # shown in the printed table above. combo_pool_columns (below) is a
156
+ # narrower set used only for combo-building, so a bad column can
157
+ # still be seen in the single-column table but can't sneak a win
158
+ # into a combo just because cap > 1 let it ride along.
104
159
  top_columns = [col for col, r in printed if r is not None]
105
160
  edge_map = {col: float(r["edge_point"]) for col, r in printed if r is not None}
106
161
 
107
- mode_caps = {"quick": 2, "standard": 3}
108
- if mode == "deep":
109
- cap = _auto_cap_from_budget(len(top_columns), MAX_COMBOS)
110
- if show_progress:
111
- print(f"mode='deep' -> budget rule: max combo size that keeps total "
112
- f"combos <= {MAX_COMBOS:,} from {len(top_columns)} columns "
113
- f"-> resolved to {cap}")
114
- elif mode in mode_caps:
115
- cap = mode_caps[mode]
116
- estimated = _estimate_combo_count(len(top_columns), 1, cap)
117
- if estimated > MAX_COMBOS:
118
- raise ValueError(
119
- f"mode={mode!r} (combo size {cap}) would generate ~{estimated:,} "
120
- f"combinations from {len(top_columns)} columns - that risks a "
121
- f"MemoryError, so it's refused up front instead. Try mode='deep' "
122
- f"to auto-fit a safe combo size, or raise the MAX_COMBOS "
123
- f"constant at the top of this module if you really mean it."
124
- )
162
+ n_rows = len(train_df)
163
+
164
+ # Two independent guards against small-df overfitting, kept
165
+ # deliberately separate and always applied - no mode switch, this is
166
+ # just how combo generation works now:
167
+ # 1. combo SIZE - decided purely by row count (_cap_from_rows),
168
+ # floored further by the memory budget so it still can never
169
+ # blow up regardless of row count.
170
+ # 2. combo ELIGIBILITY - any column whose own hit_rate can't beat
171
+ # baseline_full (the dataset's "do nothing" rate) is dropped
172
+ # from the combo-building pool entirely, so it can't pair up
173
+ # with a good column and win on luck alone. It still appears in
174
+ # the printed single-column table above - only combo
175
+ # participation is affected.
176
+ memory_cap = _auto_cap_from_budget(len(top_columns), MAX_COMBOS)
177
+ rows_cap = _cap_from_rows(n_rows)
178
+ cap = min(memory_cap, rows_cap)
179
+
180
+ baseline_full = _baseline_hit_rate(train_df, outcome_col)
181
+ if baseline_full is not None:
182
+ hit_rate_map = {col: float(r["hit_rate"]) for col, r in printed if r is not None}
183
+ dropped = [col for col in top_columns if hit_rate_map[col] < baseline_full]
184
+ combo_pool_columns = [col for col in top_columns if col not in dropped]
125
185
  else:
126
- raise ValueError(f"mode must be 'quick', 'standard', or 'deep', got {mode!r}")
186
+ dropped = []
187
+ combo_pool_columns = top_columns
127
188
 
128
- all_combos = combo_generation(top_columns, max_size=cap)
129
-
130
- # combo scoring runs unconditionally - show_progress only toggles the
131
- # printing below, the winner still needs to be returned either way.
132
189
  if show_progress:
133
- print(f"\n=== COMBOS (mode={mode!r}, {len(top_columns)} columns, "
134
- f"max combo size {cap}) ===")
190
+ print(f"\n\033[1m=== {len(combo_pool_columns)} COLUMN(S) ELIGIBLE FOR COMBO GENERATION ===\033[0m\n")
191
+ print(f"Memory cap = {memory_cap}, Row cap ({n_rows} rows) = {rows_cap} -> resolved to {cap}.")
192
+ if baseline_full is not None:
193
+ print(f"Baseline = {baseline_full:.2f}; dropped {len(dropped)}/{len(top_columns)} column(s) below baseline.\n")
194
+
195
+ # Same headers/to_row/fmt_row used for the full RECOMMEND table
196
+ # above, just re-run on the narrower eligible-columns subset so
197
+ # this table shows each column's actual stats, not bare names.
198
+ row_lookup = dict(printed)
199
+ eligible_rows = [to_row(col, row_lookup[col]) for col in combo_pool_columns]
200
+ eligible_widths = [
201
+ max(len(str(h)), *(len(str(row[i])) for row in eligible_rows)) if eligible_rows else len(str(h))
202
+ for i, h in enumerate(headers)
203
+ ]
204
+ def fmt_eligible_row(vals):
205
+ return " ".join(str(v).ljust(w) for v, w in zip(vals, eligible_widths))
206
+ print(fmt_eligible_row(headers))
207
+ print(" ".join("-" * w for w in eligible_widths))
208
+ for row in eligible_rows:
209
+ print(fmt_eligible_row(row))
210
+
211
+ print(f"\n\033[1m\u26a0\ufe0f THIS IS WHERE THE MAGIC HAPPENS, MIGHT TAKE A WHILE, BE PATIENT. "
212
+ f"\U0001F552 {datetime.now().strftime('%H:%M:%S')}.\033[0m\n")
213
+
214
+ if debug:
215
+ for col in combo_pool_columns:
216
+ print(col)
217
+
218
+ all_combos = combo_generation(combo_pool_columns, max_size=cap)
219
+
220
+ # combo scoring runs unconditionally - debug only toggles the verbose
221
+ # per-combo printing below, the winner still needs to be returned
222
+ # either way.
223
+ if debug:
224
+ print(f"\n=== COMBOS ({len(combo_pool_columns)} columns "
225
+ f"eligible, max combo size {cap}) ===")
135
226
 
136
227
  combo_results = []
137
228
  for i, combo in enumerate(all_combos, start=1):
@@ -142,7 +233,7 @@ def recommend(
142
233
  # / RELIEF_SECONDS above if it's still too hot.
143
234
  time.sleep(RELIEF_SECONDS)
144
235
 
145
- if show_progress:
236
+ if debug:
146
237
  print(f"Combo {i}")
147
238
  name_width = max(len(col) for col in combo)
148
239
  for col in combo:
@@ -157,7 +248,7 @@ def recommend(
157
248
  above_n = int(above_mask.sum())
158
249
 
159
250
  if above_n == 0:
160
- if show_progress:
251
+ if debug:
161
252
  print(" -> nothing clears the AND filter, can't compute hit_rate/gap/coverage")
162
253
  combo_results.append({"combo_num": i, "combo": combo, "hit_rate": None,
163
254
  "miss_rate": None, "gap": None, "shrunk_gap": None,
@@ -169,20 +260,20 @@ def recommend(
169
260
  gap = hit_rate - miss_rate
170
261
  coverage = (above_n / total_n) * 100.0
171
262
  shrunk_gap = _shrink_gap(gap, above_n, shrinkage_k)
172
- if show_progress:
263
+ if debug:
173
264
  print(f" hit_rate={hit_rate:.2f}% miss_rate={miss_rate:.2f}% "
174
265
  f"gap={gap:+.2f}pp shrunk_gap={shrunk_gap:+.2f}pp "
175
266
  f"n={above_n} coverage={coverage:.2f}%")
176
267
  combo_results.append({"combo_num": i, "combo": combo, "hit_rate": hit_rate,
177
268
  "miss_rate": miss_rate, "gap": gap, "shrunk_gap": shrunk_gap,
178
269
  "n": above_n, "coverage": coverage, "population": total_n})
179
- if show_progress:
270
+ if debug:
180
271
  print()
181
272
 
182
- if show_progress:
183
- effective_max_size = min(cap, len(top_columns))
273
+ if debug:
274
+ effective_max_size = min(cap, len(combo_pool_columns))
184
275
  print(f"{len(all_combos)} total combinations printed "
185
- f"(sizes 1 to {effective_max_size} of {len(top_columns)} columns)")
276
+ f"(sizes 1 to {effective_max_size} of {len(combo_pool_columns)} eligible columns)")
186
277
 
187
278
  scored_combos = [c for c in combo_results if c["hit_rate"] is not None]
188
279
  scored_combos = [c for c in scored_combos if c["coverage"] >= min_coverage]
@@ -230,7 +321,7 @@ def recommend(
230
321
  ]
231
322
  combo_summary_df = combo_summary_df[[c for c in ordered_cols if c in combo_summary_df.columns]]
232
323
 
233
- if show_progress:
324
+ if debug:
234
325
  print(f"\n=== COMBO SUMMARY ({len(combo_summary_df)} qualifying combos) ===")
235
326
  display_df = combo_summary_df.drop(columns=["edges", "population"], errors="ignore")
236
327
  with pd.option_context(
@@ -2,7 +2,7 @@ import pandas as pd
2
2
 
3
3
 
4
4
  def validate_search_params(
5
- df, outcome_col, mode, min_coverage, show_progress, top_combo_n,
5
+ df, outcome_col, min_coverage, show_progress, top_combo_n,
6
6
  ):
7
7
  """
8
8
  Validates every edgepoint.search() param for the right datatype and,
@@ -24,39 +24,20 @@ def validate_search_params(
24
24
  row-count floor), so re-checking them here would be redundant at
25
25
  best and stricter-than-necessary at worst, since it'd reject data
26
26
  sanity_check_columns is designed to clean up rather than reject.
27
-
28
- mode is normalized (surrounding whitespace stripped, case folded to
29
- lowercase) before being checked against its allowed values - so
30
- " Quick ", "QUICK", and "quick" all validate the same way. The
31
- normalized string is returned as `mode` so the caller (search())
32
- uses the exact same cleaned-up spelling everywhere downstream,
33
- instead of whatever casing/whitespace the caller happened to type.
34
27
  """
35
28
  if not isinstance(df, pd.DataFrame):
36
29
  raise TypeError(f"df must be a pandas DataFrame, got {type(df).__name__}")
37
-
38
30
  if not isinstance(outcome_col, str):
39
31
  raise TypeError(f"outcome_col must be a str, got {type(outcome_col).__name__}")
40
32
  if outcome_col not in df.columns:
41
33
  raise ValueError(f"outcome_col={outcome_col!r} not found in df")
42
-
43
- if not isinstance(mode, str):
44
- raise TypeError(f"mode must be a str, got {type(mode).__name__}")
45
- mode_clean = mode.strip().lower()
46
- if mode_clean not in ("quick", "standard", "deep"):
47
- raise ValueError(f"mode must be 'quick', 'standard', or 'deep', got {mode!r}")
48
-
49
34
  if isinstance(min_coverage, bool) or not isinstance(min_coverage, (int, float)):
50
35
  raise TypeError(f"min_coverage must be a number, got {type(min_coverage).__name__}")
51
36
  if not (0 <= min_coverage <= 100):
52
37
  raise ValueError(f"min_coverage must be between 0 and 100, got {min_coverage}")
53
-
54
38
  if not isinstance(show_progress, bool):
55
39
  raise TypeError(f"show_progress must be a bool, got {type(show_progress).__name__}")
56
-
57
40
  if isinstance(top_combo_n, bool) or not isinstance(top_combo_n, int):
58
41
  raise TypeError(f"top_combo_n must be an int, got {type(top_combo_n).__name__}")
59
42
  if top_combo_n < 1:
60
- raise ValueError(f"top_combo_n must be >= 1, got {top_combo_n}")
61
-
62
- return mode_clean
43
+ raise ValueError(f"top_combo_n must be >= 1, got {top_combo_n}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: edgepoint
3
- Version: 3.0.0
3
+ Version: 3.0.2
4
4
  Summary: Find the point in a numeric column above which a binary outcome becomes meaningfully and reliably better.
5
5
  Author: Henry
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "edgepoint"
7
- version = "3.0.0"
7
+ version = "3.0.2"
8
8
  description = "Find the point in a numeric column above which a binary outcome becomes meaningfully and reliably better."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -5,7 +5,7 @@ long_description = (Path(__file__).parent / "README.md").read_text(encoding="utf
5
5
 
6
6
  setup(
7
7
  name="edgepoint",
8
- version="3.0.0",
8
+ version="3.0.2",
9
9
  author="Henry",
10
10
  description="Find where a metric, or combination of metrics, starts getting better while keeping the best coverage possible, then check if that holds on unseen data.",
11
11
  long_description=long_description,
@@ -1,251 +0,0 @@
1
- import pandas as pd
2
- from datetime import datetime
3
- from .run_kfold_find import run_kfold_find
4
- from .train_combo import recommend
5
- from .aggregate_kfold_combos import aggregate_kfold_combos
6
- from .utils import (
7
- GAP_WEIGHT,
8
- SHRINKAGE_K,
9
- )
10
- from .utils import _score_combos_on_test
11
-
12
- def _baseline_hit_rate(df, outcome_col):
13
- """mean(outcome_col)*100 with no threshold filter - the "do nothing" baseline."""
14
- y = df[outcome_col].dropna()
15
- if len(y) == 0:
16
- return None
17
- return float(y.astype(int).mean()) * 100.0
18
-
19
- def _print_table(display_df):
20
- # Own print style - no longer routed through main.py's _print_df/
21
- # _print_df_full. Whether the caller passed the full df or an
22
- # already-head(5)-limited one is decided before this is called;
23
- # this just prints whatever it's given, consistently formatted,
24
- # same option_context recipe used in aggregate_kfold_combos.py.
25
- with pd.option_context(
26
- "display.width", 1000, "display.max_columns", None,
27
- "display.max_colwidth", None, "display.expand_frame_repr", False,
28
- ):
29
- print(display_df)
30
-
31
-
32
- def train_and_combo_gen(
33
- df,
34
- outcome_col,
35
- columns=None,
36
- min_coverage=20,
37
- mode="quick",
38
- show_progress=True,
39
- debug=False,
40
- ):
41
- # debug=True always turns on show_progress - debug tables only ever
42
- # print inside the show_progress-gated blocks below, so debug=True
43
- # with show_progress=False would otherwise print nothing at all.
44
- if debug:
45
- show_progress = True
46
-
47
- train_results_df, folds_df = run_kfold_find(
48
- df,
49
- outcome_col,
50
- columns=columns,
51
- min_coverage=min_coverage,
52
- gap_weight=GAP_WEIGHT,
53
- shrinkage_k=SHRINKAGE_K,
54
- show_progress=show_progress,
55
- )
56
-
57
- # ---- GLOBAL qualify bar = the overall positive rate on the FULL df,
58
- # computed before any folding/splitting happens. This replaces the
59
- # old MIN_QUALIFY_POINTS ("top_hit_rate - 33.33") approach entirely -
60
- # the bar is no longer relative to whatever the best combo happens to
61
- # score. It's fixed, absolute, and simple: a combo that can't beat
62
- # "doing nothing" (the dataset's own overall hit rate) is disqualified,
63
- # no matter how it compares to other combos. This single value is
64
- # reused as-is in every per-fold gate below AND passed straight into
65
- # aggregate_kfold_combos for its own final gate - never recomputed
66
- # from a shrinking fold or from the aggregated table's own top, so
67
- # the bar never drifts anywhere in this pipeline.
68
- baseline_full = _baseline_hit_rate(df, outcome_col)
69
-
70
- if show_progress and baseline_full is not None:
71
- print(f"\n[debug] global qualify bar (overall positive rate): {baseline_full:.2f}")
72
-
73
- # original, un-narrowed combos from fold[0] - kept around unmodified
74
- # so aggregate_kfold_combos can always look up a *surviving* combo's
75
- # fixed edges/name by combo_num.
76
-
77
- if show_progress:
78
- print(f"\n\u26a0\ufe0f THIS IS WHERE THE MAGIC HAPPENS, MIGHT TAKE A WHILE, BE PATIENT. \U0001F552 {datetime.now().strftime('%H:%M:%S')}.\n")
79
-
80
- full_combos_df = recommend(
81
- folds_df[0],
82
- train_results_df,
83
- outcome_col=outcome_col,
84
- min_coverage=min_coverage,
85
- gap_weight=GAP_WEIGHT,
86
- shrinkage_k=SHRINKAGE_K,
87
- mode=mode,
88
- show_progress=False,
89
- )
90
-
91
- if show_progress:
92
- display_df = full_combos_df[["combo_num", "combo", "hit_rate", "coverage", "n", "score"]].copy()
93
- display_df = display_df if debug else display_df.head(5)
94
- print(f"=== FOLD 1 VALIDATION ({len(full_combos_df)} combo(s) survived) ===")
95
- _print_table(display_df)
96
-
97
- # this one narrows every round - only the currently-surviving combos
98
- # get tested against the next fold. By the time the loop ends,
99
- # surviving_combos_df holds ONLY the combos that made it through
100
- # every single fold - this is the set that should get aggregated.
101
- surviving_combos_df = full_combos_df
102
- combo_fold_results = []
103
- for i, fold in enumerate(folds_df[1:], start=2):
104
- if surviving_combos_df.empty:
105
- break
106
-
107
- going_in = len(surviving_combos_df)
108
- fold_combos_df = _score_combos_on_test(
109
- fold, outcome_col, surviving_combos_df, min_coverage, GAP_WEIGHT, SHRINKAGE_K
110
- )
111
-
112
- # _score_combos_on_test's own internal filter - anything that
113
- # went in but isn't in its output failed min_coverage in this
114
- # fold. Counted here (rather than inside that function) since
115
- # this is the only place that knows both "going in" and "came
116
- # out" counts. Same accounting test_and_combo_val.py does.
117
- after_coverage = len(fold_combos_df)
118
- dropped_coverage = going_in - after_coverage
119
-
120
- # ---- per-fold qualify gate ---------------------------------
121
- # _score_combos_on_test only filters by min_coverage - it does NOT
122
- # check the qualify bar. Without this, a combo could dip below
123
- # the overall baseline positive rate, still "survive" on coverage
124
- # alone, and get carried into the aggregate - letting one bad
125
- # fold get diluted/hidden by good folds instead of disqualifying
126
- # the combo outright. Uses the GLOBAL baseline_full (fixed once,
127
- # computed on the full df before any folding) - not this fold's
128
- # own top hit_rate - so the bar never drifts. Any combo below
129
- # baseline_full in THIS fold is dropped now, before it can
130
- # survive into the next fold.
131
- dropped_baseline = 0
132
- if not fold_combos_df.empty and baseline_full is not None:
133
- fold_hit_rates = fold_combos_df["hit_rate"].astype(float)
134
- before_baseline = len(fold_combos_df)
135
- fold_combos_df = fold_combos_df[fold_hit_rates >= baseline_full].reset_index(drop=True)
136
- dropped_baseline = before_baseline - len(fold_combos_df)
137
-
138
- if show_progress:
139
- print(f"\nfold {i}: in={going_in} coverage_drop={dropped_coverage} "
140
- f"baseline_drop={dropped_baseline} remain={len(fold_combos_df)}")
141
-
142
- combo_fold_results.append(fold_combos_df)
143
-
144
- if show_progress:
145
- # Built directly here instead of going through
146
- # _format_combos_df_for_display() (in main.py) - that helper
147
- # pulls in train_hit_rate/train_coverage columns from
148
- # _score_combos_on_test's own output, which we don't want
149
- # showing up in this table. Selecting/formatting the columns
150
- # directly removes that dependency entirely.
151
- display_df = fold_combos_df[["combo_num", "combo", "hit_rate", "coverage", "n"]].copy()
152
- # _score_combos_on_test returns raw full-precision floats for
153
- # hit_rate/coverage (unlike recommend()'s fold-1 output, which
154
- # already comes pre-stringified to 2dp) - force the same 2dp
155
- # convention here so this table matches every other printout.
156
- display_df["hit_rate"] = display_df["hit_rate"].map(lambda v: f"{float(v):.2f}")
157
- display_df["coverage"] = display_df["coverage"].map(lambda v: f"{float(v):.2f}")
158
- if "score" in fold_combos_df.columns:
159
- display_df["score"] = fold_combos_df["score"]
160
- display_df = display_df if debug else display_df.head(5)
161
- print(f"\n=== FOLD {i} VALIDATION ({len(fold_combos_df)} combo(s) survived) ===")
162
- _print_table(display_df)
163
-
164
- if fold_combos_df.empty:
165
- surviving_combos_df = fold_combos_df
166
- break
167
-
168
- surviving_nums = set(fold_combos_df["combo_num"])
169
- surviving_combos_df = full_combos_df[full_combos_df["combo_num"].isin(surviving_nums)].reset_index(drop=True)
170
-
171
- # ---- restrict aggregation to only the FINAL survivors -------------
172
- # combo_fold_results still contains early-fold entries for combos
173
- # that got eliminated partway through the walkthrough (e.g. dropped
174
- # at fold 3 of 5). Without filtering, aggregate_kfold_combos would
175
- # happily aggregate votes for combos that never made it to the end.
176
- # So: only combo_nums present in the final surviving_combos_df are
177
- # kept, both in the lookup table (full_combos_df) and in every
178
- # per-fold snapshot (combo_fold_results), before aggregating.
179
- final_surviving_nums = set(surviving_combos_df["combo_num"]) if not surviving_combos_df.empty else set()
180
-
181
- filtered_full_combos_df = full_combos_df[
182
- full_combos_df["combo_num"].isin(final_surviving_nums)
183
- ].reset_index(drop=True)
184
-
185
- filtered_combo_fold_results = [
186
- fold_df[fold_df["combo_num"].isin(final_surviving_nums)].reset_index(drop=True)
187
- for fold_df in combo_fold_results
188
- ]
189
-
190
- if show_progress:
191
- print(f"\n[debug] {len(final_surviving_nums)} combo(s) survived every fold - "
192
- f"aggregating only these (out of {len(full_combos_df)} originally generated).")
193
-
194
- final_fold = folds_df[-1]
195
-
196
- # ---- baseline_full is now passed straight into aggregate_kfold_combos
197
- # instead of being re-applied here afterward. aggregate_kfold_combos
198
- # runs the exact same "hit_rate >= baseline_full" check internally, on
199
- # its own aggregated/honest full-df hit_rate, BEFORE it scores/sorts/
200
- # prints its summary - so the printed table and the returned df are
201
- # already baseline-filtered by the time they come back here. This is
202
- # the SAME single fixed bar used by every per-fold gate above; it is
203
- # not recomputed anywhere.
204
- aggregated_combos_df = aggregate_kfold_combos(
205
- df, outcome_col, filtered_full_combos_df, filtered_combo_fold_results,
206
- gap_weight=GAP_WEIGHT, shrinkage_k=SHRINKAGE_K, show_progress=False,
207
- baseline_full=baseline_full,
208
- min_coverage=min_coverage,
209
- )
210
-
211
- # NOTE: the old post-hoc "final qualify gate" block that re-filtered
212
- # aggregated_combos_df by baseline_full has been removed - that
213
- # filtering now happens INSIDE aggregate_kfold_combos itself (see
214
- # above), so aggregated_combos_df arriving here is already gated.
215
-
216
- # ---- final surviving combos, returned RAW from recommend() --------
217
- # No join, no recompute. filtered_full_combos_df is recommend()'s own
218
- # untouched output (from the fold[0] call above), already filtered
219
- # down to just the combo_nums that survived every fold and (via
220
- # aggregate_kfold_combos) the final baseline qualify gate. Its
221
- # columns are exactly what recommend() itself returns for its own
222
- # qualified combos - combo_num, combo, hit_rate, miss_rate, gap, n,
223
- # coverage, population, score, edges - so nothing here is invented or
224
- # pulled from the aggregate step.
225
- final_qualified_nums = set(aggregated_combos_df["combo_num"]) if not aggregated_combos_df.empty else set()
226
- top_combos_df = filtered_full_combos_df[
227
- filtered_full_combos_df["combo_num"].isin(final_qualified_nums)
228
- ].reset_index(drop=True)
229
-
230
- # ---- overwrite hit_rate with the AGGREGATED, baseline-gated number
231
- # -------------------------------------------------------------------
232
- # filtered_full_combos_df's own hit_rate column is recommend()'s raw
233
- # fold[0]-only output - it was never touched by aggregate_kfold_combos
234
- # and never gated against baseline_full. final_qualified_nums only
235
- # filters WHICH rows survive, it does not fix up what hit_rate value
236
- # those surviving rows carry - so without this step, top_combos_df
237
- # would still report each combo's single-fold hit_rate, not the
238
- # aggregated full-df number that actually cleared the baseline gate.
239
- # Downstream (test_and_combo_val.py, via include_train_hit_rate=True)
240
- # reads this same hit_rate column to populate train_hit_rate - so if
241
- # it's left as the raw fold[0] number, train_hit_rate can show below
242
- # baseline_full even though the combo only qualified because its
243
- # AGGREGATED hit_rate cleared the bar. Mapping in aggregated_combos_df's
244
- # hit_rate here (already gated, already stringified 2dp) makes
245
- # top_combos_df's hit_rate - and therefore train_hit_rate downstream -
246
- # consistent with the gate that actually let the combo through.
247
- if not top_combos_df.empty and not aggregated_combos_df.empty:
248
- agg_hit_rate_map = dict(zip(aggregated_combos_df["combo_num"], aggregated_combos_df["hit_rate"]))
249
- top_combos_df["hit_rate"] = top_combos_df["combo_num"].map(agg_hit_rate_map)
250
-
251
- return train_results_df, folds_df, top_combos_df
File without changes
File without changes
File without changes
File without changes
File without changes