Metadata-Version: 2.4
Name: edgepoint
Version: 3.5.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": [
        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 `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). 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:

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