Metadata-Version: 2.4
Name: edgepoint
Version: 2.0.1
Summary: Find the point in a numeric column above which a binary outcome becomes meaningfully and reliably better.
Author: Henry
License: MIT
Keywords: threshold,decision-threshold,binary-classification,feature-engineering,data-analysis,analytics,statistics,edge,threshold-detection
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Science/Research
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Scientific/Engineering
Classifier: Topic :: Scientific/Engineering :: Information Analysis
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: numpy>=1.21
Requires-Dist: pandas>=1.5
Provides-Extra: test
Requires-Dist: pytest>=7.0; extra == "test"
Dynamic: license-file
Dynamic: requires-python

# 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?"**

Rather than fitting a predictive model, `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."

```python
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.

---

## 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:

- One or more **numeric columns** to evaluate.
- One **binary outcome column** indicating whether each observation was a
  success or not.

The outcome column may contain `0`/`1`, `True`/`False`, `0.0`/`1.0`, or any
mixture of the above. Any other value (strings, `2`, `-1`, etc.) raises a
`ValueError` up front, before anything runs.

| 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

```bash
pip install edgepoint
```

```python
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:

```python
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

```python
from edgepoint import search

train_results_df, test_results_df, top_combos = search(
    df,
    outcome_col="hit",
    mode="quick",
    min_coverage=20,
    top_combos_n=2,
    show_progress=True,
)
```

Running that prints a five-stage progress log (suppress it with
`show_progress=False`):

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

 start : 2026-07-16 22:28:05.

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

train n=140. test n=60. total n=200.

 done in 0mins 0 secs | elapsed 0mins 1 secs | 22:28:06.


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

  column edge_point hit_rate miss_rate     gap    n coverage
0  score      65.67    92.11      7.89   84.21   38    27.14
1    age      18.00    43.57     56.43  -12.86  140   100.00

 done in 0mins 0 secs | elapsed 0mins 1 secs | 22:28:06.


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

  column edge_point hit_rate miss_rate     gap   n coverage
0  score      65.67    80.00     20.00   60.00  20    33.33
1    age      18.00    41.67     58.33  -16.67  60   100.00

 done in 0mins 0 secs | elapsed 0mins 2 secs | 22:28:07.


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

 2 combo(s) qualified (train).

   combo_num       combo hit_rate miss_rate    gap   n coverage
0          1       score    92.11      7.89  84.21  38    27.14
1          3  score, age    92.11      7.89  84.21  38    27.14

 done in 0mins 0 secs | elapsed 0mins 2 secs | 22:28:08.


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

 2 combo(s) qualified (test).

   combo_num       combo hit_rate coverage   n train_hit_rate train_coverage  score
0          1       score    80.00    33.33  20          92.11          27.14    1.0
1          3  score, age    80.00    33.33  20          92.11          27.14    1.0

 done in 0mins 0 secs | elapsed 0mins 3 secs | 22:28:08.


TOP 2 COMBOS (test)
--------------------------------
                   : combo #1         combo #3
 score             : 65.67            65.67
 age               : -                18.0
 ......................................................
 hit_rate          : 80.0             80.0
 coverage          : 33.33            33.33
 train_hit_rate    : 92.11            92.11
 train_coverage    : 27.14            27.14
 combo_sample_size : 20               20
 test_sample_size  : 60               60

COMPLETE  -  total duration 0mins 4 secs  (end 22:28:09).
```

And the three values `search()` returns:

```python
>>> train_results_df
  column edge_point hit_rate miss_rate     gap    n coverage
0  score      65.67    92.11      7.89   84.21   38    27.14
1    age      18.00    43.57     56.43  -12.86  140   100.00

>>> test_results_df
  column edge_point hit_rate miss_rate     gap   n coverage
0  score      65.67    80.00     20.00   60.00  20    33.33
1    age      18.00    41.67     58.33  -16.67  60   100.00

>>> top_combos
[{'combo_num': 1, 'edges': {'score': 65.67}, 'hit_rate': 80.0, 'coverage': 33.33,
  'train_hit_rate': 92.11, 'train_coverage': 27.14, 'combo_sample_size': 20, 'test_sample_size': 60},
 {'combo_num': 3, 'edges': {'score': 65.67, 'age': 18.0}, 'hit_rate': 80.0, 'coverage': 33.33,
  'train_hit_rate': 92.11, 'train_coverage': 27.14, 'combo_sample_size': 20, 'test_sample_size': 60}]
```

Reading this: `score`'s threshold of 65.67 looked strong on train (92.11%
hit rate over 27.14% of rows) and, replayed unchanged against test, held up
at 80.00% over 33.33% of rows, a real but expected drop, not a collapse.
Adding `age` to the combo changed nothing here (its own threshold barely
qualified on train), which is itself a useful, honest result.

---

## 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:

```python
{
    "combo_num": 1,
    "edges": {"score": 65.67},          # {column: edge_point} used by this combo
    "hit_rate": 80.0,                   # test-side
    "coverage": 33.33,                  # test-side
    "train_hit_rate": 92.11,            # carried over from train, unchanged
    "train_coverage": 27.14,            # carried over from train, unchanged
    "combo_sample_size": 20,            # rows clearing the combo on test
    "test_sample_size": 60,             # total test rows
}
```

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.

---

## Who this is for

Not primarily ML researchers with an established modeling toolbox already
in hand, they don't need this to solve the raw math. This is for people
closer to how it was actually built: solo builders, indie quants, and
analysts who would rather read and trust code they can fully audit 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

**Does this replace machine learning?**
No. Machine learning predicts outcomes; `edgepoint` proposes interpretable
operational thresholds and checks them against held-out data.

**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
