Metadata-Version: 2.4
Name: edgepoint
Version: 3.0.0
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.

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

```python
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 `0`s or almost all `1`s) leave little contrast
  to find an edge across, and columns are likely to get skipped for
  failing `min_coverage`.

---

## Quick start

```python
import pandas as pd
from edgepoint import search

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
    ]
})

edgepoints_df, top_combos = search(
    df,
    outcome_col="hit",
    mode="quick",
    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 `0`s or almost all
  `1`s.

---

## 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), then qualifying columns
   are combined into AND-combinations (size capped by `mode`) 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.

---

## Modes

`mode` controls how large a column combination can get during combo
search:

| Mode         | Combo size                                                          |
| ------------ | -------------------------------------------------------------------- |
| `"quick"`    | up to 2 columns per combo                                            |
| `"standard"` | up to 3 columns per combo                                             |
| `"deep"`     | auto-fit, the largest size that keeps total combos under a fixed safety ceiling |

`"quick"`/`"standard"` are refused up front if they'd blow past that
ceiling given how many columns qualified.

---

## Parameters

| Parameter      | Default | Description                                                                 |
| -------------- | ------- | ----------------------------------------------------------------------------- |
| `df`           | required | Input DataFrame                                                              |
| `outcome_col`  | `"hit"` | Binary outcome column (`0`/`1`, `True`/`False`, or a mix)                     |
| `mode`         | `"quick"` | `"quick"`, `"standard"`, or `"deep"`, controls combo size, see above       |
| `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:

```python
{
    "combo_num": 3,
    "combo": "score, age",
    "edges": {"score": 49.46, "age": 39.5},
    "hit_rate_tr": 80.0, "hit_rate_te": 60.0,
    "coverage_tr": 41.67, "coverage_te": 31.25,
    "n_tr": 10, "n_te": 5,
    "score_tr": 0.62, "score_te": 0.41,
    "final_score": 21.49,
}
```

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 customers 34+ buy at 58% vs 22%
for everyone else, useful, but crude, since plenty of 22-year-olds buy
too. A combo sharpens that: `age >= 34 AND minutes_on_site >= 6.5` might
come back at a 71% hit rate on train and 68% on test, covering 18% of
visitors against a 24% baseline. That's a different, more useful claim.
It's not age alone, it's that age *stacked with* engagement time is
where the real signal was hiding, and it held up on data the search
never saw. `top_combos` typically hands you a few of these to compare,
so you can weigh, say, "age + time on site" against "past purchases +
email opens," and pick whichever combo 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.
- **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
