Metadata-Version: 2.4
Name: sparpartner
Version: 0.1.0
Summary: Deterministic, benchmark-driven stratified sampler for train/test prep.
Author: Henry
Author-email: Henry <osas2henry@gmail.com>
License: All Rights Reserved
Classifier: License :: Other/Proprietary License
Classifier: Programming Language :: Python :: 3
Classifier: Operating System :: OS Independent
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: pandas>=1.3
Dynamic: author
Dynamic: license-file
Dynamic: requires-python

# sparpartner

A deterministic stratified sampler for train/test prep. It doesn't
split your data randomly, it deliberately finds the rows that most
resemble a benchmark case, so you can hold those out as a genuine
test set and train on everything else.

## Why this exists

A random train/test split assumes the test set should look
statistically like the train set. That's the wrong question if
what you actually want to know is: **does the model generalize past
one specific profile, or did it just memorize the neighborhood
around it?**

`sparpartner` answers that by ranking every row in your data by how
closely it resembles a benchmark ("bench_marks") you define, then
sorting on that resemblance. You then slice the sorted frame
yourself:

- **Lookalikes as test, the rest as train**: train on rows *unlike*
  the benchmark, test on the rows that *are* like it. This is the
  harder, more honest check: it tells you whether the model actually
  learned something general, or only performs well near cases it's
  already seen a lot of.
- **Lookalikes as train, the rest as test**: the reverse, if you
  want to check the opposite direction.

`sparpartner` only produces the ranking. The actual train/test cut
is a plain slice on your side (see [Usage](#usage) below).

## Where the idea comes from

Two unrelated places, both about comparing something against a
reference on purpose rather than at random:

**A boxer preparing for a match.** A fighter in camp doesn't spar
with whoever's free in the gym, they specifically look for a
sparring partner who moves, reaches, and hits like the opponent
they're about to face. Training against a random partner tells you
nothing about how you'll actually do; training against someone who
resembles the real threat does. `sparpartner` applies that same
logic to a model: instead of a random holdout, it finds the rows
that resemble the toughest, most relevant "opponent" profile and
holds those back as the real test, so what's left to train on is
everything *unlike* that opponent, and the test genuinely checks
whether the model can handle the match it's actually walking into.

**Astrology's approach to comparing a chart to a reference.** Reading
a chart against a benchmark isn't a single yes/no match, it's
several weighted dimensions (sun sign, moon sign, rising, houses,
and so on) each compared individually, then combined into one
overall resemblance reading. No single dimension decides the
outcome alone; the composite is what matters. That's the same shape
`sparpartner` uses: several weighted signals, each scored
independently against a benchmark, summed into one composite
`_score` rather than a single all-or-nothing match.

## How the scoring works

You give it:

- `df`: your data
- `bench_marks`: a dict of `{column_name: benchmark_value}`, one
  entry per signal you care about
- `custom_weights`: a list of `[column_name, weight]` pairs, saying
  which columns matter and how much

For each weighted column, `sparpartner` auto-detects the column's
type and scores every row's distance to the benchmark on a 0–1
scale (1.0 = exact match, 0.0 = as far as possible):

| Detected type | How distance is measured |
|---|---|
| **numeric** | `abs(value - bench)`, capped by the column's own max observed distance from bench |
| **date** | both sides converted to "age in days" relative to the benchmark date, capped by the column's own max observed age |
| **string** | exact match = 1, anything else = 0 |

Date detection is automatic, it only treats a column as a date if
its values look date-shaped (contain a separator like `-`, `/`, `.`
or a recognizable month name) **and** parse successfully at least
98% of the time. Bare numeric-looking strings (e.g. `"12345"`) are
never mistaken for dates, and object-dtype columns of digit strings
are treated as exact-match strings, not numbers. Only a real
numeric dtype gets the numeric path.

Each column's 0–1 score is multiplied by its weight and summed into
one raw score per row, then divided by the total weight so the
final `_score` always lands in the 0–1 range, however many signals
or weights you used.

### Tie-breaking

Rows that land on the exact same `_score` aren't left to random or
arbitrary order. Ties are broken by the per-signal score of the
**highest-weight** signal first, then the next-highest, cascading
down the weighted signal list until the tie resolves. Signals that
share the same weight are compared in the order they appear in
`custom_weights`. The tie-break always sorts in the same direction
as the main score (see `look_alike` below). Only if every signal is
exhausted and rows are still tied does it fall back to original row
order.

## `look_alike`

Controls sort direction:

- `look_alike=True` (default): highest `_score` (closest to
  benchmark) sorted to the **top**.
- `look_alike=False`: highest `_score` sorted to the **bottom**,
  farthest-from-benchmark rows come first.

## Usage

### Sample usage

```python
import pandas as pd
from sparpartner import sample

df = pd.DataFrame({
    "id": [1, 2, 3, 4, 5],
    "age": [25, 30, 47, 52, 33],
    "signup_date": ["2023-01-15", "2023-03-02", "2022-11-20", "2023-01-10", "2023-06-01"],
    "country": ["US", "US", "CA", "US", "MX"],
})

bench_marks = {
    "age": 30,
    "signup_date": "2023-01-01",
    "country": "US",
}

custom_weights = [
    ["age", 2],
    ["signup_date", 1],
    ["country", 1],
]

result = sample(
    df,
    bench_marks,
    custom_weights,
    look_alike=True,      # True = most similar rows first
    show_progress=True,   # prints the full scoring breakdown
    return_score=True,    # keep _score columns in the output
    drop_nan=True,
)

print(result)
```

`age=30`, `signup_date="2023-01-01"`, and `country="US"` closely
match row `id=1` (age 25, close date, US), so with
`look_alike=True` that row lands at or near the top of the sorted
output. Flip `look_alike=False` to instead surface the rows that
look *least* like the benchmark (e.g. the Mexico row with the
furthest signup date).

```python
# --- post-sample: turn the ranking into an actual train/test split ---
# Re-run with look_alike=False so lookalikes sort to the BOTTOM of `result`.
ranked = sample(df, bench_marks, custom_weights, look_alike=False)

# Slice however large you want the test set to be, e.g. the bottom 30%:
cut = int(len(ranked) * 0.7)
train = ranked.iloc[:cut]   # non-lookalikes
test = ranked.iloc[cut:]    # lookalikes, the harder, honest test set
```

### Parameters

| Name | Type | Default | What it does |
|---|---|---|---|
| `df` | DataFrame | required | Must contain a column for every name in `custom_weights` |
| `bench_marks` | dict | required | `{column_name: benchmark_value}` |
| `custom_weights` | list of `[name, weight]` | required | Which columns to score and how much each contributes |
| `look_alike` | bool | `True` | `True` = lookalikes sorted to top; `False` = sorted to bottom |
| `show_progress` | bool | `False` | Prints a full readout: header, per-column type/cap/sample scores, sort direction, top/bottom ranked rows with weighted contributions, and a health check flagging any signal that isn't adding useful separation |
| `return_score` | bool | `False` | If `True`, keeps `_score` and `_score_<name>` columns in the result instead of dropping them |
| `drop_nan` | bool | `True` | If `True`, drops any row with a NaN component score (e.g. missing value or unparseable date) after printing a sanity check of what was dropped and why |

### Validation

`custom_weights` is validated upfront, before any scoring starts, and
raises `ValueError` if:

- a column name isn't a string, or doesn't exist in `df`
- a column name has no matching entry in `bench_marks`
- the same column name appears more than once
- a weight isn't numeric (bools are rejected too, a `bool` is
  technically an `int` in Python but was never meant as a weight)
- a weight is `NaN`

## A couple of things worth knowing

- **Zero-variance columns**: if every row (including the benchmark)
  has the same value for a signal, there's nothing to measure
  distance against, that signal scores every row 1.0 rather than
  dividing by zero.
- **Negative total weight**: if your weights sum to ≤ 0, the
  normalization step is skipped and `_score` is left as the raw
  weighted sum instead of being silently divided by a non-positive
  number.
- **Object-dtype numeric strings**: a column of strings like
  `"100"`, `"200"` (object dtype, no separator) is scored as an
  exact-match string column, *not* auto-converted to numeric. Only
  genuine numeric dtypes (`int`, `float`) get the numeric distance
  path.
