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.
- {edgepoint-3.0.0 → edgepoint-3.0.2}/PKG-INFO +1 -1
- {edgepoint-3.0.0 → edgepoint-3.0.2}/edgepoint/main.py +59 -39
- edgepoint-3.0.2/edgepoint/run_kfold_search.py +192 -0
- {edgepoint-3.0.0 → edgepoint-3.0.2}/edgepoint/sanity_check.py +1 -1
- {edgepoint-3.0.0 → edgepoint-3.0.2}/edgepoint/train_combo.py +134 -43
- {edgepoint-3.0.0 → edgepoint-3.0.2}/edgepoint/validations.py +2 -21
- {edgepoint-3.0.0 → edgepoint-3.0.2}/edgepoint.egg-info/PKG-INFO +1 -1
- {edgepoint-3.0.0 → edgepoint-3.0.2}/pyproject.toml +1 -1
- {edgepoint-3.0.0 → edgepoint-3.0.2}/setup.py +1 -1
- edgepoint-3.0.0/edgepoint/run_kfold_search.py +0 -251
- {edgepoint-3.0.0 → edgepoint-3.0.2}/LICENSE +0 -0
- {edgepoint-3.0.0 → edgepoint-3.0.2}/README.md +0 -0
- {edgepoint-3.0.0 → edgepoint-3.0.2}/edgepoint/__init__.py +0 -0
- {edgepoint-3.0.0 → edgepoint-3.0.2}/edgepoint/aggregate_kfold_combos.py +0 -0
- {edgepoint-3.0.0 → edgepoint-3.0.2}/edgepoint/aggregate_kfold_edges.py +0 -0
- {edgepoint-3.0.0 → edgepoint-3.0.2}/edgepoint/core.py +0 -0
- {edgepoint-3.0.0 → edgepoint-3.0.2}/edgepoint/core_v1.py +0 -0
- {edgepoint-3.0.0 → edgepoint-3.0.2}/edgepoint/main_old.py +0 -0
- {edgepoint-3.0.0 → edgepoint-3.0.2}/edgepoint/run_kfold_find.py +0 -0
- {edgepoint-3.0.0 → edgepoint-3.0.2}/edgepoint/split_router.py +0 -0
- {edgepoint-3.0.0 → edgepoint-3.0.2}/edgepoint/stratified_folds.py +0 -0
- {edgepoint-3.0.0 → edgepoint-3.0.2}/edgepoint/test_and_combo_val.py +0 -0
- {edgepoint-3.0.0 → edgepoint-3.0.2}/edgepoint/utils.py +0 -0
- {edgepoint-3.0.0 → edgepoint-3.0.2}/edgepoint.egg-info/SOURCES.txt +0 -0
- {edgepoint-3.0.0 → edgepoint-3.0.2}/edgepoint.egg-info/dependency_links.txt +0 -0
- {edgepoint-3.0.0 → edgepoint-3.0.2}/edgepoint.egg-info/requires.txt +0 -0
- {edgepoint-3.0.0 → edgepoint-3.0.2}/edgepoint.egg-info/top_level.txt +0 -0
- {edgepoint-3.0.0 → edgepoint-3.0.2}/setup.cfg +0 -0
|
@@ -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(
|
|
47
|
-
# Printed once, before any stage - app title +
|
|
48
|
-
#
|
|
49
|
-
|
|
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.
|
|
181
|
-
|
|
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]
|
|
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":
|
|
346
|
-
"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",
|
|
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.
|
|
396
|
-
|
|
397
|
-
|
|
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(
|
|
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,
|
|
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",
|
|
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 =
|
|
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
|
|
15
|
-
#
|
|
16
|
-
#
|
|
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
|
-
|
|
21
|
-
|
|
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
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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===
|
|
134
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
270
|
+
if debug:
|
|
180
271
|
print()
|
|
181
272
|
|
|
182
|
-
if
|
|
183
|
-
effective_max_size = min(cap, len(
|
|
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(
|
|
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
|
|
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,
|
|
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}")
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "edgepoint"
|
|
7
|
-
version = "3.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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|