Skip to main content

Find the point in a numeric column above which a binary outcome becomes meaningfully and reliably better.

Project description

edgepoint

Find the point in a numeric column above which a binary outcome becomes meaningfully and reliably better, then check whether that finding actually holds up on data it never saw.

Built for data analysts who aren't deep into data science and don't want to be, who just want a quick, trustworthy answer to a data question without writing a model, tuning hyperparameters, or learning a new toolbox to get there.

A note on terms: throughout this README, "threshold", "edge point", "metric", "variable", and "column" are all used interchangeably. They all just mean a numeric column in your DataFrame.

edgepoint splits your data into train and test, searches train for the best single-column threshold(s) and the best AND-combination(s) of those thresholds, then replays every one of those exact picks against test with no re-optimization. What you get back isn't just "the best split we could find", it's that, plus an honest readout of how it held up on data it never touched.

from edgepoint import search

edgepoints_df, top_combos = search(df, outcome_col="hit")

Correlation is not causation

search() finds correlations, not causes. A great-looking threshold doesn't mean crossing it causes the outcome to improve, it may be riding along with something else entirely, or just noise. This risk is worse on small data: an 80% hit rate on 5 rows is far more likely to be a fluke than the same 80% on 500 rows.

So supervise what you pass in. Only include columns with a plausible reason to relate to the outcome, treat thin n results as leads to investigate rather than decisions to act on, and re-run as more data arrives rather than locking in a threshold found on a thin slice.


Where this applies

Any continuous metric (or several) plus a binary outcome pair, for example:

  • Marketing: at what engagement score does churn risk drop off
  • Credit: at what score does default rate become acceptable
  • Healthcare: at what biomarker level does an outcome rate jump
  • Product: at what usage count does upgrade to paid rate spike
  • Manufacturing: at what sensor reading does defect rate climb
  • Trading / betting: at what signal strength value (or combination of signals) does a pick's hit rate become reliable

outcome_col: telling edgepoint what "good" means

outcome_col is your 0/1 (or True/False) judgment of what counts as a win on each row. edgepoint has no idea what a good sale, patient outcome, or bet looks like in your business. Everything else it computes (hit_rate, gap, coverage, score) is just counting, over and over, against the column you point it at.

  • It must already exist. Derive it (e.g. "closed within 7 days") before calling search().
  • One outcome per run. Two definitions of success means two calls.
  • Rare outcomes (almost all 0s or almost all 1s) leave little contrast to find an edge across, and columns are likely to get skipped for failing min_coverage.

Quick start

import pandas as pd
from edgepoint import search

df = pd.DataFrame({
    "score": [
        63.94, 22.32, 89.22, 2.98, 2.65, 54.49, 80.94, 69.81,
        95.72, 9.67, 80.71, 97.31, 82.94, 57.74, 22.79, 23.28,
        63.57, 20.95, 64.80, 72.91, 98.95, 68.46, 22.90, 26.77,
        87.64, 39.56, 26.49, 26.27, 39.94, 50.95, 10.96, 42.22,
        99.61, 86.08, 68.17, 64.10, 45.37, 26.34, 91.26, 63.89
    ],
    "age": [
        19.2, 52.6, 22.1, 28.3, 27.3, 28.4, 18.3, 34.0,
        33.8, 57.8, 52.3, 35.8, 47.1, 51.1, 31.6, 22.7,
        35.1, 30.5, 46.6, 25.7, 48.1, 57.6, 19.5, 27.9,
        32.8, 61.0, 29.6, 45.5, 28.3, 22.3, 47.5, 21.0,
        42.9, 18.5, 43.2, 23.2, 62.8, 41.5, 58.9, 46.6
    ],
    "hit": [
        1, 0, 0, 0, 0, 0, 0, 1,
        1, 0, 1, 0, 0, 1, 1, 0,
        1, 0, 1, 1, 1, 1, 0, 0,
        0, 0, 0, 0, 0, 1, 0, 0,
        0, 0, 1, 0, 0, 1, 1, 1
    ]
})

edgepoints_df, top_combos = search(
    df,
    outcome_col="hit",
    min_coverage=10,
    show_progress=True,
    top_combo_n=3,
)

With show_progress=True, search() prints a 6 stage log (data sanity/splitting, train search, test validation, merged edges, merged combos, top combo summary) as it runs. With show_progress=False it runs silently and just returns the two values below.


Auto split & auto fold

There's no train_split or k parameter to set. Both are worked out for you from the data itself:

  • Split: search() takes df exactly as ordered and treats the oldest rows as train, the newest rows as test (a walk forward split; train never sees the future relative to test). The train/test ratio is picked automatically from row count. If your rows aren't already in chronological order, or you don't want a chronological split at all, shuffle df yourself before passing it in. search() never reorders or randomizes rows on its own.
  • Folds: within train and again within test, the number of k-folds used for validation is auto-sized off row count and min_coverage, so each fold still has enough rows to mean something. Folds themselves are randomly stratified, rows are shuffled and split so each fold keeps roughly the same positive rate as the whole set, rather than plain random slicing that could hand one fold almost all 0s or almost all 1s.

How the pipeline works

  1. Data sanity / splitting: df is cleaned first. Invalid outcome rows dropped, True/False normalized to 1/0, non-numeric, constant, or near-unique predictor columns dropped, remaining NaN/inf filled. The cleaned data is then split into train/test automatically, chronologically, based on row count, with no split ratio to configure.
  2. Train section: every numeric column is searched for its best single-column threshold (percentile-based candidate edges, scored on a shrinkage-discounted gap/coverage blend). Columns that clear the baseline are then combined into AND-combinations, capped at a combo size worked out automatically (see below), and k-fold validated on train itself.
  3. Test section: every train-side edge and surviving combo is replayed against test, unchanged, with its own k-fold validation.
  4. Merge edges: train and test side stats for each column are merged side by side (_tr/_te suffixes) into edgepoints_df, ranked by a blended final_score.
  5. Merge combos: same idea, but only combos that qualified on both train and test survive.
  6. Top combos: the best top_combo_n combos are printed and returned as top_combos.

A combo (or column) only counts as a real edge if its hit rate clears the global baseline, the dataset's own overall hit rate ("doing nothing"), computed once on the full df and reused, unchanged, at every stage. Nothing is ever re-tuned on test.


Automatic combo-size capping (no mode to set)

Earlier versions asked you to pick a mode ("quick" / "standard" / "deep") to control how large a column combination could get. That's gone. The cap is now worked out automatically for every run, from two independent limits, and the smaller one always wins:

  • Row cap: how big a combo can you actually trust given how much data you have? ANDing four conditions together on 76 rows slices the data down to almost nothing per combo, that's a straight overfitting risk, not a compute one. This cap comes purely from row count.
  • Memory cap: how big a combo can the machine afford to generate before the number of combinations blows past a fixed safety ceiling? This cap comes purely from column count, nothing statistical about it.

search() always uses min(row_cap, memory_cap), so a combo size is never allowed past whichever limit is more restrictive. This removes a real footgun: a mode="deep" setting on a small dataset used to let combo size grow well past what the row count could statistically support, manufacturing an impressive-looking combo that was really just overfitting a handful of rows. Now that ceiling is enforced automatically on every run, there's no setting to accidentally crank up and no need to know how many rows is "enough" for a given combo size, search() works it out for you and never lets you shoot yourself in the foot.


Parameters

Parameter Default Description
df required Input DataFrame
outcome_col "hit" Binary outcome column (0/1, True/False, or a mix)
min_coverage 10 Minimum % of rows a threshold/combo must cover to qualify (train and test)
show_progress True Print the full stage-by-stage log
top_combo_n 3 How many top combos to return. 1 returns a single dict; more returns a list

Returns

search() returns a 2-tuple: (edgepoints_df, top_combos).

edgepoints_df: one row per column that appeared on either side, with train (_tr) and test (_te) versions of hit_rate, miss_rate, gap, n, coverage, plus a blended final_score, sorted best first.

top_combos: a single dict if top_combo_n=1, else a list of up to top_combo_n dicts, each shaped like:

{
    "combo_num": 2,
    "combo": "score, age",
    "edges": {"score": 55.0, "age": 40.0},
    "hit_rate_tr": 83.33, "hit_rate_te": 75.0,
    "coverage_tr": 20.0, "coverage_te": 40.0,
    "n_tr": 6, "n_te": 4,
    "score_tr": 0.71, "score_te": 0.55,
    "final_score": 64.6,
    "baseline_full": 40.0,
    "vs_baseline": 35.0,
}

If nothing qualified, you get a single dict with combo_num: None and empty edges.

What a combo actually tells you

Think of top_combos as the answer to "which traits, combined, actually separate my winners from everyone else, and by how much?" A single-column result might tell you score >= 55 alone gets you a 66.67% hit rate on train (covering half the rows) and age >= 40 alone only gets you 50%, useful, but each is still riding along with a lot of noise. A combo sharpens that: score >= 55.0 AND age >= 40.0 comes back at an 83.33% hit rate on train and 75.0% on test, covering a thinner but much sharper 20-40% of rows, against a 40% baseline. That's the real claim, it's not score alone or age alone, it's that the two stacked together is where the actual separation was hiding, and it held up on data the search never saw (vs_baseline: 35.0 means even the weaker of the two splits still beat "doing nothing" by 35 points). top_combos typically hands you a few of these to compare, so you can weigh one combo's strength against another's, and pick whichever is both strong and backed by enough rows (n_tr/n_te) to actually trust.


Using the result correctly

The thresholds search() returns aren't meant to become permanent. They're the best candidates given the data available right now, checked once against a held-out slice of that same data. Re-run search() as new rows arrive. Each run rechecks the spike against a fresh split and tells you plainly if it moved, held, or disappeared. Treat it as proposing and stress-testing a threshold once, not certifying one forever.


Supported outcome values

outcome_col may contain 0/1, True/False, 0.0/1.0, or any mix of the above. Any other value (strings, 2, -1, etc.) raises a ValueError before any column is searched. A full stop, not a per-column skip.


Design notes

  • Percentile-based candidate edges: candidate edges follow the column's own quantiles, so dense clusters get finer cuts and sparse tails get wider ones.
  • Weighted score, not hard cutoffs: min_coverage is a hard gate; among what clears it, gap and coverage are blended into one score rather than requiring both to clear fixed minimums independently.
  • Shrinkage-discounted gaps: a dramatic gap on a handful of rows is discounted toward zero before scoring, so it can't outrank a smaller, better-supported gap. Raw hit rate/gap are always what's reported.
  • Sign-flip penalty: if a combo's gap direction flips between train and test, that's a concrete overfitting signal, so final_score is halved for that row.
  • Automatic combo-size capping: combo size is capped by the stricter of a row-count-based statistical limit and a memory-based safety ceiling, see "Automatic combo-size capping" above, there's no mode to set and no way to override this cap.
  • One-directional search: edgepoint only asks "is there a point above which the rate is better?" It doesn't independently verify the below side against its own bar.

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

edgepoint-3.5.0.tar.gz (63.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

edgepoint-3.5.0-py3-none-any.whl (68.4 kB view details)

Uploaded Python 3

File details

Details for the file edgepoint-3.5.0.tar.gz.

File metadata

  • Download URL: edgepoint-3.5.0.tar.gz
  • Upload date:
  • Size: 63.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.9

File hashes

Hashes for edgepoint-3.5.0.tar.gz
Algorithm Hash digest
SHA256 3531e6cec930adbc34206b780a83d617092ed0f6fd6448dbe3075f95bc3a7e5b
MD5 6c30326459cf85ea9f0c48ac41fc8193
BLAKE2b-256 7ba3dc0c62563e679117fd2ac47d8d6ccf33747f6795763fe6f38aa3d73b106d

See more details on using hashes here.

File details

Details for the file edgepoint-3.5.0-py3-none-any.whl.

File metadata

  • Download URL: edgepoint-3.5.0-py3-none-any.whl
  • Upload date:
  • Size: 68.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.9

File hashes

Hashes for edgepoint-3.5.0-py3-none-any.whl
Algorithm Hash digest
SHA256 eee3c7b7cbba733fc7228f8f8855c337872a3b451ae28b63d3ad588278e3143a
MD5 34232ee70fa54328a7c9e678eee0ee9d
BLAKE2b-256 4fc1ac0a7eff05b0eab6d66c7beb2c3911252295ac406994853da520ba111260

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page