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 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 databench_marks: a dict of{column_name: benchmark_value}, one entry per signal you care aboutcustom_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_scoresorted to the bottom, farthest-from-benchmark rows come first.
Usage
Sample usage
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).
# --- 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
boolis technically anintin 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
_scoreis 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.
Metadata
Release files for sparpartner 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| sparpartner-0.1.0.tar.gz | 15.8 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| sparpartner-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 28.6 kB
Release files / sparpartner-0.1.0.tar.gz
| Download URL | sparpartner-0.1.0.tar.gz |
|---|---|
| Size | 15.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
4e00ff8d1a19d8f4b6d76a4ad29dd632c725c31811f7a481d00f0cad54a67777
|
|
BLAKE2b-256 checksum How to use checksums |
00a6d927faa6b179e8ac667f16475d11cd8dc59d9d979c16b1cec7f84b4ab2bb
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.9
|
Release files / sparpartner-0.1.0-py3-none-any.whl
| Download URL | sparpartner-0.1.0-py3-none-any.whl |
|---|---|
| Size | 12.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
53b606e513a8c08b6705992b2c027ca1ee5c748c4d0b055c245e9400893b1c75
|
|
BLAKE2b-256 checksum How to use checksums |
e6ed703daaa5e80b3fc94f4dc22302379f97c8d5994765a96c6256ae619da5ee
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.9
|
Security for sparpartner 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.