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.

edgepoint is a lightweight Python library for discovering interpretable thresholds in numeric data. Given one or more numeric features and a binary outcome, it answers a practical question, twice:

"Where does this metric, or this combination of metrics, start becoming good enough while keeping the best coverage possible, and does that still hold on unseen data?"

edgepoint makes a simple, less-savvy-visible property of your data easy to see: the exact point along a metric where an outcome starts spiking. edgepoint splits your data into train and test, searches train for the best threshold(s), then replays those exact thresholds against test with no re-optimization. What you get back is not just "the best split we could find" but "the best split we could find, and here's how it actually performed on data it never touched."

from edgepoint import search

train_results_df, test_results_df, top_combos = search(
    df,
    outcome_col="hit",   # any binary (0/1 or True/False) column
)

train_results_df and test_results_df are one-row-per-column tables of single-variable thresholds; top_combos is the best AND-combination(s) of those thresholds, scored and validated the same way. All three are described below.


outcome_col: telling edgepoint what "good" means

Before search() scans a single edge, it needs one thing from you that matters more than any parameter: outcome_col. This is you, the analyst, labeling every row in your data as a win or a loss, a pass or a fail, a positive or a negative. edgepoint doesn't decide what "success" looks like in your business, it has no idea what a good sale, a good patient outcome, or a good bet even is. You decide that, once, by pointing at a column, and every other number the library produces is downstream of that one judgment.

Concretely: outcome_col must be a column of 1/0, True/False, or a mix of both, one value per row, 1 (or True) meaning "this row counts as a success" and 0 (or False) meaning "this row doesn't." That's it, no other values are allowed, edgepoint raises a ValueError before doing any work if it finds anything else in that column, rather than silently guessing which values you meant as the "good" ones.

Once that column exists, everything else in search() is really just counting, over and over, at every candidate edge in every numeric column: "of the rows at or above this point, what fraction were a 1?" That fraction is hit_rate. The rest, miss_rate, gap, coverage, score, are all built directly on top of that one count. Get outcome_col wrong, too loose, too strict, mislabeled, or measuring the wrong thing entirely, and every threshold search() proposes will be confidently optimized for the wrong target. Get it right, and the rest of the pipeline (percentile edges, train/test replay, combo search) is just honest, mechanical counting against a target you already trust.

A few practical notes on choosing it:

  • It has to already exist as a column. If "success" in your data is a derived condition, e.g. "sale closed within 7 days" or "score improved by at least 10 points", compute that as its own 0/1 column before calling search(). edgepoint labels nothing on its own.
  • One outcome per run. If you care about two different definitions of success (say, "converted" and "converted and retained"), that's two separate calls to search() with two different outcome_cols, not one call trying to do both.
  • Rare outcomes still need rows on both sides. If almost every row is a 1 (or almost every row is a 0), there's very little contrast for edgepoint to find an edge across, and columns are likely to get skipped for failing min_coverage on whichever side is thin.

Where this applies

Any continuous metric (or several) + 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 (the original use case this was built for)

What you need

You only need two things: one or more numeric columns to evaluate, and the outcome_col described above, your 0/1 (or True/False) judgment of what counts as a win on each row. A few example pairings:

Numeric column Outcome column
Credit score Defaulted (True / False)
Engagement score Converted (1 / 0)
Biomarker level Improved (True / False)
Model confidence Correct prediction (1 / 0)
Betting signal score Win (True / False)
Risk score Fraud (1 / 0)

Install

pip install edgepoint
from edgepoint import search

Quick start

Sample data

Everything below runs on this exact dataset, 40 rows of a score and an age column against a binary hit outcome, hit was generated so that it depends mostly on score (higher score, better odds) with age added in as mostly noise. Build it directly as a DataFrame and follow along:

import pandas as pd

df = pd.DataFrame({
    "score": [
        72.68, 58.92, 62.15, 8.13, 33.3, 66.19, 73.79, 35.43,
        65.06, 63.13, 61.47, 45.13, 39.16, 38.15, 29.41, 59.91,
        54.12, 86.35, 76.02, 63.13, 51.63, 0, 77.4, 81.66,
        50.13, 52.4, 52.82, 86.12, 57.58, 13.66, 37.29, 32.91,
        73.66, 96.2, 36.3, 22.74, 65.54, 23.98, 61.59, 32.27
    ],
    "age": [
        33.9, 41.9, 30.8, 56.5, 45.8, 45.1, 43.3, 54.3,
        52.5, 54.2, 43.9, 18, 31.3, 38, 48.3, 55.8,
        35.4, 56, 20.2, 27.7, 28.1, 39.4, 44.3, 39.9,
        53.6, 26.1, 63.1, 42.3, 46.3, 20.2, 39.2, 35.3,
        27.7, 31.8, 26.5, 32.2, 35.1, 43.5, 45.8, 43.8
    ],
    "hit": [
        1, 1, 0, 0, 0, 1, 1, 0,
        0, 1, 1, 0, 0, 0, 0, 1,
        1, 1, 1, 1, 0, 0, 0, 1,
        0, 1, 1, 1, 0, 0, 0, 0,
        1, 1, 0, 0, 1, 0, 1, 0
    ]
})

Running it

from edgepoint import search

train_results_df, test_results_df, top_combos = search(
    df,
    outcome_col="hit",
    train_split=60,      # 24 train / 16 test → both ≥ min_rows
    min_rows=15,
    min_coverage=20,
    mode="quick",
    top_combos_n=2,
    show_progress=False,
)

With show_progress=False the call above runs silently and just returns the three values. Turning show_progress=True on the exact same call prints the full five-stage log so you can see how the split, the single-column search, and the combo search actually got there:

EDGEPOINT.
--------------------------------
 -> 🟢  QUICK     <  3 mins  (active)
    🟡  STANDARD  >  3 mins
    🔴  DEEP      > 10 mins

 start : 2026-07-17 07:10:14.

[1/5] Train / Test Split
--------------------------------

Sample Size (n) - Full = 40   . Train = 24   . Test = 16   .

Hit Rate    (%) - Full = 47.50. Train = 50.00. Test = 43.75.

 done in 0mins 0 secs | elapsed 0mins 1 secs | 07:10:15.


[2/5] Single-Column Search (train, n=24)
--------------------------------

  column edge_point hit_rate miss_rate    gap   n coverage
0  score      49.46    75.00     25.00  50.00  16    66.67
1    age      39.50    57.14     42.86  14.29  14    58.33

 done in 0mins 0 secs | elapsed 0mins 1 secs | 07:10:16.


[3/5] Single-Column Evaluation (test, n=16)
--------------------------------

  column edge_point hit_rate miss_rate     gap  n coverage
0  score      49.46    77.78     22.22   55.56  9    56.25
1    age      39.50    42.86     57.14  -14.29  7    43.75

 done in 0mins 0 secs | elapsed 0mins 2 secs | 07:10:16.


[4/5] Combo Generation & Evaluation (train, mode='quick') ..This may take a while.
--------------------------------

 3 combo(s) qualified (train). sample size (n) = 24. hit rate (%) = 50.00.

   combo_num       combo hit_rate miss_rate    gap   n coverage
0          3  score, age    80.00     20.00  60.00  10    41.67
1          1       score    75.00     25.00  50.00  16    66.67
2          2         age    57.14     42.86  14.29  14    58.33

 done in 0mins 0 secs | elapsed 0mins 2 secs | 07:10:17.


[5/5] Combo Validation (test, mode='quick')
--------------------------------

 3 combo(s) qualified (test). sample size (n) = 16. hit rate (%) = 43.75.

   combo_num       combo hit_rate coverage  n train_hit_rate train_coverage  score
0          3  score, age    60.00    31.25  5          80.00          41.67  21.49
1          1       score    77.78    56.25  9          75.00          66.67   1.00
2          2         age    42.86    43.75  7          57.14          58.33 -29.50

 done in 0mins 0 secs | elapsed 0mins 3 secs | 07:10:17.


TOP 2 COMBOS (test)
--------------------------------

Sample Size (n) - Full = 40   . Train = 24   . Test = 16   .
Hit Rate    (%) - Full = 47.50. Train = 50.00. Test = 43.75.

                             : combo #3         combo #1
 Score                       : 49.46            49.46
 Age                         : 39.5             -
 ......................................................
 Hit_rate                    : 60.0             77.78
 Coverage                    : 31.25            56.25
 Train_hit_rate              : 80.0             75.0
 Train_coverage              : 41.67            66.67
 Combo_sample_size           : 5                9
 Test_sample_size            : 16               16
 Baseline_hit_rate (full df) : 47.50            47.50
 Vs_baseline                 : 12.50 +          30.28 +

COMPLETE  -  total duration 0mins 4 secs  (end 07:10:18).

And the three values search() returns:

>>> train_results_df
  column edge_point hit_rate miss_rate    gap   n coverage
0  score      49.46    75.00     25.00  50.00  16    66.67
1    age      39.50    57.14     42.86  14.29  14    58.33

>>> test_results_df
  column edge_point hit_rate miss_rate     gap  n coverage
0  score      49.46    77.78     22.22   55.56  9    56.25
1    age      39.50    42.86     57.14  -14.29  7    43.75

>>> top_combos
[{'combo_num': 3, 'edges': {'score': 49.46, 'age': 39.5}, 'hit_rate': 60.0, 'coverage': 31.25,
  'train_hit_rate': 80.0, 'train_coverage': 41.67, 'combo_sample_size': 5, 'test_sample_size': 16,
  'baseline_hit_rate': 47.5, 'vs_baseline': 12.5},
 {'combo_num': 1, 'edges': {'score': 49.46}, 'hit_rate': 77.78, 'coverage': 56.25,
  'train_hit_rate': 75.0, 'train_coverage': 66.67, 'combo_sample_size': 9, 'test_sample_size': 16,
  'baseline_hit_rate': 47.5, 'vs_baseline': 30.28}]

Reading this: score's threshold of 49.46 looked solid on train (75.00% hit rate over 66.67% of rows) and, replayed unchanged against test, held up even better at 77.78% over 56.25% of rows, comfortably ahead of the 47.5% baseline hit rate. Adding age into the combo (score >= 49.46 AND age >= 39.5) tightens things further on train (80.00% hit rate) but shrinks coverage a lot (down to 5 rows on test), and its test hit rate of 60.00%, while still above baseline, is the less reliable of the two picks precisely because it's resting on so few rows, exactly the kind of honest trade-off search() is built to surface rather than hide.


How the pipeline works

search() runs five stages every time:

  1. Train / test split - sample_method controls how: "asc" (default) splits chronologically, train = the oldest train_split% of rows, test = the newer remainder, a walk-forward split where train never sees the future relative to test. "desc" is the mirror image: train = the most recent chunk, test = the older chunk before it. "random" shuffles rows first, which only makes sense when rows are independent observations with no meaningful time ordering.

  2. Single-column search (train) - for every numeric column, candidate edges are laid out along the column's own percentile distribution (not equal-width), so dense clusters of values get finer-grained candidate edges and sparse tails get wider ones. At each candidate edge, edgepoint computes the hit rate above the edge, the hit rate below it, the gap between them, and the coverage (% of rows at or above the edge). Edges below min_coverage are discarded. Among what's left, each edge is scored as a weighted blend of its gap and its coverage, and the single best-scoring edge per column is kept. One row per qualifying column is returned as train_results_df; columns with too few rows, a constant range, or no edge that clears min_coverage are simply absent, not None, just not present.

  3. Single-column evaluation (test) - every winning threshold from step 2 is replayed against test data exactly as-is, no re-optimization, no re-scoring. This is a pure readout of how each train-side pick performs on unseen data, returned as test_results_df with the same shape as train_results_df.

  4. Combo generation & evaluation (train) - using only the columns that qualified in step 2, edgepoint builds AND-combinations of their thresholds (score >= x AND age >= y, etc.), evaluates each combination on train the same way (hit rate, gap, coverage), and keeps only combinations that clear min_coverage and whose hit rate is within a fixed tolerance of the best combination found. mode controls how large a combination can get, see below.

  5. Combo validation (test) - every surviving combination's exact thresholds are replayed against test, with a fresh coverage check run on test itself. A combination that comfortably cleared min_coverage on train can still get dropped here if it doesn't hold up on test. What's left is re-scored and sorted, and the top top_combos_n are returned.

The whole point of steps 3 and 5 is that nothing is ever re-tuned on test, you're seeing exactly how train's picks generalize, not a second round of fitting.


Modes

mode controls how large a column-combination is allowed to get in step 4:

Mode Combo size Typical runtime
"quick" up to 2 columns per combo < 3 mins
"standard" up to 3 columns per combo (default) > 3 mins
"deep" auto-fit, the largest combo size that keeps total combinations under a fixed safety ceiling, given however many columns qualified in step 2 > 10 mins

"quick" and "standard" are refused up front (before any real work starts) if the number of qualifying columns would blow past that same safety ceiling at the requested combo size, better a clear error than a runaway search.


Parameters

Parameter Default Description
df required Input DataFrame
outcome_col required Binary outcome column (0/1, True/False, or a mix)
columns None Columns to consider. Defaults to every numeric column except outcome_col
min_coverage 20 Minimum % of rows that must clear a threshold for it to qualify, applied independently on train and test, for both single columns and combos
mode "quick" "quick", "standard", or "deep", controls combo size, see above
sample_method "asc" "asc", "desc", or "random", how the train/test split is made
train_split 70 % of rows assigned to train
top_combos_n 1 How many top combos to return (capped at 3). 1 returns a single dict; more returns a list
show_progress True Print the full five-stage banner/progress log
min_rows 15 Minimum rows a column needs (after dropping missing values) before it's searched at all
range_bins 15 Number of percentile-based candidate edges evaluated per column
debug False Print every qualifying row at each stage instead of a top-5 preview

Two things intentionally aren't exposed as parameters: the gap-vs-coverage score weighting and the small-sample shrinkage discount (below) are held fixed internally so that single-column search, combo search, and test-side combo scoring all rank on the exact same tradeoff. If you need to tune those directly, edgepoint.core.find() and edgepoint.train_combo.recommend() are the lower-level functions search() builds on, see their own docstrings for the full parameter reference.


Returns

search() returns a 3-tuple: (train_results_df, test_results_df, top_combos).

train_results_df - one row per qualifying column from step 2: column, edge_point, hit_rate, miss_rate, gap, n, coverage, sorted best-scoring column first.

test_results_df - the same columns, one row per column that appeared in train_results_df, built by replaying each train-side edge against test. hit_rate/miss_rate/gap/coverage read "n/a" and n is 0 if nothing on the test side clears that edge.

top_combos - a single dict if top_combos_n=1 (the default), or a list of up to top_combos_n dicts otherwise. Each dict:

{
    "combo_num": 3,
    "edges": {"score": 49.46, "age": 39.5},  # {column: edge_point} used by this combo
    "hit_rate": 60.0,                        # test-side
    "coverage": 31.25,                       # test-side
    "train_hit_rate": 80.0,                  # carried over from train, unchanged
    "train_coverage": 41.67,                 # carried over from train, unchanged
    "combo_sample_size": 5,                  # rows clearing the combo on test
    "test_sample_size": 16,                  # total test rows
    "baseline_hit_rate": 47.5,               # "do nothing" hit rate on the full df
    "vs_baseline": 12.5,                     # test hit_rate - baseline_hit_rate, in points
}

If nothing qualified on test at all, you get a single dict with combo_num: None and empty edges, regardless of top_combos_n.


Using the result correctly

The thresholds search() returns are not intended 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. As more observations arrive, the best threshold, and how well it holds on new data, can shift, strengthen, weaken, or disappear entirely.

search() answers exactly one question:

"Given the data I have right now, split honestly into train and test, where's the best candidate threshold, and how did it actually hold up?"

It deliberately does not answer:

"How long should I trust this threshold going forward?"

That responsibility belongs to whatever system calls this repeatedly over time: recompute periodically, check more often while data is thin, widen tolerance as evidence accumulates, and demote or remove thresholds that stop performing. Think of search() as proposing and stress-testing a threshold once, not certifying one forever.


Supported outcome values

The outcome column may contain:

  • 0 / 1
  • True / False
  • 0.0 / 1.0
  • any mixture 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

Why percentile-based candidate edges instead of equal-width bins? Candidate edges are placed along the column's own quantiles, so dense clusters of values get finer-grained cuts and sparse tails get wider ones, as a natural consequence of where the data actually sits. Duplicate edge values collapse automatically, which matters for count-like or heavily rounded columns.

Why a weighted score instead of hard gap/rate thresholds? Rather than requiring a threshold to clear a fixed minimum gap and a fixed minimum success rate, edgepoint scores every coverage-qualified candidate as a blend of its gap and its coverage, and picks the best-scoring one. Coverage is still a hard gate (min_coverage), nothing under that bar competes at all, but among what's left, the tradeoff between "how big is the edge" and "how much of the population does it cover" is continuous, not a pass/fail cliff.

Why discount small-sample gaps before scoring? A dramatic-looking gap sitting on a handful of rows is less trustworthy than a smaller gap backed by hundreds of rows. Before the gap enters the scoring step, it's discounted toward zero in proportion to how few rows support it (more rows → smaller discount), so a flashy small-sample edge can no longer automatically outscore a well-supported one. The raw, undiscounted gap and hit rate are always what's reported back, the discount only affects which edge wins, not what's displayed.

Why a train/test split at all, if it's still "one shot"? Because a threshold search run only on the full dataset can't tell you whether the pattern it found generalizes or was a fluke of that particular data. Splitting into train and test, and re-evaluating train's exact picks, with no retuning, against test, gives an honest read on the second question without pretending it answers "how long will this hold in the future."

One-directional search. edgepoint only ever asks "is there a point above which the rate is better than below it?" It does not independently score the below-side against its own bar; "below" is only known to be worse than "above," a comparative statement, not two independently verified zones.


A good alternative choice, at any data size

This isn't tied to a data size at all, it scales from a few dozen rows up to millions the same way: scan every candidate point, count what's above and below it, replay the winner on held-out data. The core idea holds no matter how much data feeds it.

The core idea: make the spike visible. Most datasets have some point along a metric where the outcome rate stops being flat and starts climbing, but that point is easy to miss just eyeballing a column. edgepoint scans every candidate point along every numeric column and surfaces exactly where that climb happens, edge_point, hit_rate, gap, coverage, in a plain table. No hidden weights, no coefficients to decode, just "here's the row count on each side of this line, and here's how much better things get above it."

Built to keep re-checking rather than lock in. Data keeps arriving, and a good threshold today can shift as more of it comes in. Rather than fitting one answer and treating it as settled, search() is meant to be re-run as new rows accumulate, each run rechecks the spike against a fresh train/test split and tells you plainly if it moved, held, or disappeared. See "Using the result correctly" above, that habit of recomputing is what keeps the picture honest over time, regardless of how many rows are behind it.

Worth knowing on any dataset. A good-looking gap can sometimes be a handful of rows doing all the work rather than a real, reliable spike, this shows up more easily on a smaller slice of data, but the same logic applies at any scale. edgepoint leans against this two ways: min_coverage refuses to let a threshold win on too few rows in the first place, and the built-in shrinkage discount (see Design notes) discounts small-sample gaps before they're allowed to win. Still worth a glance at the n column yourself, if a combo's win is resting on very few rows relative to the rest of your data, that's exactly the kind of thing the recompute habit above is there to confirm or correct as more data comes in.


Who this is for

Solo builders, indie quants, and analysts who want to see exactly where a metric's outcome rate spikes, in a table they can read and trust at a glance, without needing a modeling toolbox to get there. This is for people closer to how it was actually built: readers who'd rather audit code they fully understand than hand a threshold decision to a library's internals, and who want a basic train/test sanity check built in rather than bolted on separately.


FAQ

What does edgepoint actually give me? A plain, readable table of exactly where an outcome starts spiking along a metric, plus proof that the spike held up on data it hadn't seen. No coefficients or hidden weights to decode, just an edge point, a hit rate, and a coverage number.

Is this statistically optimal? Not necessarily, and not the goal. It prioritizes interpretability and honest generalization checking over optimizing a pure statistical objective.

Why might a column or combo be missing from the results entirely? Rather than returning None for every unqualified column, edgepoint simply omits it, too few rows, a constant range, or nothing clears min_coverage. Absence is itself informative: no candidate was good enough.

Should I hard-code a threshold from search()? No. Treat it as a snapshot checked against one held-out slice, and recompute as new observations arrive. See "Using the result correctly" above.

Can I skip the train/test split and combo search, and just get single-column thresholds on the full dataset? Yes, edgepoint.core.find() is the lower-level function search() calls internally for single-column search; it can be called directly on its own DataFrame. See its own docstring for parameters.


Naming note

This package went through a few naming iterations before landing here, early candidates included optimal_start, sweet_spot, sweetspot, and maxspot, each rejected either for not capturing the actual idea (a zone/point where rate improves without sacrificing population) or for colliding with an existing PyPI package. edgepoint was checked and confirmed clear, and both the import and the PyPI distribution name now match, so there's no split between what you pip install and what you import.


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-2.0.3.tar.gz (48.1 kB view details)

Uploaded Source

Built Distribution

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

edgepoint-2.0.3-py3-none-any.whl (39.6 kB view details)

Uploaded Python 3

File details

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

File metadata

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

File hashes

Hashes for edgepoint-2.0.3.tar.gz
Algorithm Hash digest
SHA256 9b432dbc7cc5455b483c779de4813f9be9add709a21905c307b56a0e68ef369f
MD5 3ad196d616413b571bb8be6ec90e1a0d
BLAKE2b-256 e2dae201de21461ba32a2d03e84da244b876570a6a2a1ad03d1d05e03e6bc124

See more details on using hashes here.

File details

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

File metadata

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

File hashes

Hashes for edgepoint-2.0.3-py3-none-any.whl
Algorithm Hash digest
SHA256 af6a9f25dea7f350b5790ab95956c16ed204567e8ad49b872d5322eb811503e2
MD5 79ff6ff45282ec3f69053365202baa4e
BLAKE2b-256 cfbd1cdde1f93f8d4d4d3fd574f085e65dc48ac4528d5f61798e82ff1e0493f7

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