Skip to main content

sparpartner

A profile-matching engine for tabular data. You define a benchmark profile (the thing you actually care about matching, however you want to define it), sparpartner ranks every row in your data by how closely it resembles that profile, and slicer gives you a principled way to decide what "resembles" even means, instead of you guessing weights by feel.

Train/test splitting is one thing this is good for. It's not the only one. See Use cases below for others, like finding the customers, matches, or candidates that most resemble an ideal profile you define.

Why this exists

Ranking rows against a benchmark, instead of scoring them in isolation, turns out to answer a few different questions depending on what you feed it as the benchmark:

  • "Does my model generalize, or did it just memorize a neighborhood?" 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 whether the model generalizes past one specific profile. Set the benchmark to the toughest, most representative case, and the rows most like it become your test set, the harder and more honest check.
  • "Which of my customers/rows most resemble my ideal profile?" Same ranking machinery, different benchmark. Set the benchmark to an ideal customer profile instead of a "hardest test case" profile, and the exact same ranking now tells you who to prioritize, not who to hold out. See Use cases for a full walkthrough.

Either way, sparpartner answers it by ranking every row in your data by how closely it resembles a benchmark ("bench_marks") you define, then sorting closest-to-benchmark first. You then slice the sorted frame yourself, or let sparring_n and the anchor trim do it for you (see Anchor trim below):

  • Lookalikes as test, the rest as train: the rows most like the benchmark are always at the top of the sorted result, so head() gives you the test set and tail() (or everything past your cut) gives you the train set. 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.
  • Just the lookalikes, nothing else: pass sparring_n and skip the manual slice entirely. sample() hands you back only the top sparring_n rows, already ranked, plus a report on exactly that set (see the sparring report section below).
  • Only the lookalikes that hold up as a group, not just individually: pass anchors_mean alongside sparring_n (or on its own) when you want the returned set to satisfy a running condition, not just a per-row cutoff. See Anchor trim.

sparpartner only produces the ranking (and, optionally, the top-N slice, and, optionally, the anchor-trimmed slice on top of that). What you do with that ranking, a train/test cut, a shortlist of best-fit rows, or something else entirely, is on your side (see Use cases and the usage examples below).

Where the idea comes from

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.

Use cases

sample() and slicer() don't know or care what your benchmark represents, that's entirely up to what you put in bench_marks. A few different framings, same two functions.

1. Train/test split (the hardest, most honest holdout)

Set the benchmark to the toughest, most representative case you can define. The rows most like it become your test set, everything else trains the model. This is the use case covered in depth throughout the rest of this README, see Sample usage below for the full walkthrough.

2. Profile matching (finding your best-fit rows)

Same ranking, different intent. Instead of asking "which rows should I hold out as a hard test," ask "which rows most resemble the profile I actually want more of." Point bench_marks at an ideal customer, an ideal candidate, an ideal match, whatever "ideal" means for your data, and the ranking now tells you who to prioritize.

customers = df   # your customer table

ideal_customer = {
    "country": "Nigeria",
    "age": 32,
    "income": 450000,
    "last_active_date": "2026-08-20",
    "channel": "referral",
}

weights = slicer(
    source="country",
    recency="last_active_date",
    causatives=["income", "age", "channel"],
    decay_causatives=True,
    decay=0.5,
)
# {'country': 100, 'last_active_date': 50.0,
#  'income': 25.0, 'age': 12.5, 'channel': 6.25}

best_customers, report = sample(
    customers,
    bench_marks=ideal_customer,
    custom_weights=weights,
    best_first=True,
)

best_customers comes back sorted so the rows most like ideal_customer are first, exactly the same mechanics as the train/test case, just pointed at a different kind of benchmark. The slicer hierarchy is what makes this more than "customers are good because they have high income": it's saying start with the profile characteristic you actually care about most (country, the source), then progressively contextualize it with time (last_active_date), then explanatory factors (income, age, channel), in that deliberate order, instead of eyeballing weights.

bystanders fits naturally here too, a feature can be observed in sparring_report without being allowed to influence who gets selected:

SOURCE
country
   |
WHEN
last_active_date
   |
WHY / WHAT
income
age
channel
   |
BYSTANDERS (reported, not weighted)
customer_id
region
weights = slicer(
    source="country",
    recency="last_active_date",
    causatives=["income", "age", "channel"],
    decay_causatives=True,
    bystanders=["customer_id", "region"],
)

region and customer_id now show up in sparring_report as spar_region / spar_customer_id, so you can see how close matches tend to be on those dimensions too, without either one moving who actually gets ranked as a best-fit customer.

3. Anything else that reduces to "rank rows by resemblance to X"

Candidate screening against an ideal-hire profile, lead scoring against your best-converting customer, match-finding against a target opponent profile, the underlying operation is always the same "rank by resemblance to a benchmark," only the benchmark and what you do with the ranking changes.

How the scoring works

You give it:

  • df: your data. Any column names are fine, including ones starting with spar; sample() never touches or overwrites your own columns (see Validation).
  • bench_marks: a dict of {column_name: benchmark_value}, one entry per signal you care about
  • custom_weights: a dict of {column_name: weight}, saying which columns matter and how much. Insertion order is preserved and drives both the show_progress readout order and, combined with descending weight, the tie-break cascade order (see below).

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. A column is only treated 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") never even reach the date-parsing attempt, and object-dtype columns of digit strings fall through to the exact-match string path instead of being mistaken for numbers. Only a real numeric dtype gets the numeric path.

String scoring, in detail

Strings never need manual 1/0 encoding before you hand them to sample(), the string path does that for you automatically. Any column that isn't numeric, isn't datetime, and doesn't parse as a date gets scored by exact match against its benchmark:

import pandas as pd
from sparpartner import sample

df = pd.DataFrame({
    "id": [1, 2, 3, 4, 5],
    "country": ["US", "US", "CA", "US", "MX"],
})

bench_marks = {"country": "US"}
custom_weights = {"country": 1}

result, report = sample(
    df,
    bench_marks=bench_marks,
    custom_weights=custom_weights,
    best_first=True,
)

print(report)
# {'spar_country': 60.0, 'spar_min_score': 0.0, 'spar_max_score': 100.0, 'spar_mean_score': 60.0}

Under the hood, each row's country value is compared to "US" with a plain equality check:

country value matches bench "US"? per-row score
"US" yes 1.0
"US" yes 1.0
"CA" no 0.0
"US" yes 1.0
"MX" no 0.0

That gives 3 exact matches out of 5 rows, an average of 0.6, which is exactly the spar_country: 60.0 you see in the report (scores in sparring_report are always shown on a 0-100 scale, not 0-1). Rows with a matching country sort ahead of rows that don't, same as any other signal.

This is exact-match only, not fuzzy or partial-similarity matching. "Manchester United" vs "Man United" scores 0.0, the same as "Manchester United" vs "Real Madrid", there's no partial credit for near-matches the way numeric or date columns get graduated distance-based scoring.

Each column's 0-1 score is multiplied by its weight and summed into one raw score per row. That raw sum is divided by the total weight to get each row's overall match score, but only if the total weight is > 0. In that normal case the match score always lands in the 0-1 range, however many signals or weights you used. If the weights sum to <= 0, normalization is skipped entirely and the raw, unnormalized weighted sum is used instead (not guaranteed to fall in 0-1).

All of this (the per-signal 0-1 scores and the per-row overall match score) is working state sample() uses internally to sort, tie-break, and slice. None of it is added as columns to the df you get back. The df you receive always contains only your original columns, reordered (and sliced, if sparring_n and/or anchors_mean are set). Score info comes back separately, as aggregates in sparring_report, see The sparring report.

The result is always sorted by match score descending, the row closest to the benchmark is always first, this is also exactly what best_first=True means, see Row order (best_first) below.

Tie-breaking

Rows that land on the exact same match score aren't left to random or arbitrary order. Ties are broken by the per-signal score of the highest-weight signal first (higher wins), then the next-highest, cascading down the weight-sorted signal list until the tie resolves. Signals that share the same weight are compared in the order they appear in custom_weights (dict insertion order). Only if every signal is exhausted and rows are still tied does it fall back to pandas' stable sort (original row order).

Row order (best_first)

By default (best_first=True), the returned df is sorted with the closest match to the benchmark first. Pass best_first=False and, as the very last step before returning, sample() flips that same set of rows so the worst-of-selection is first and the best-of-selection is last.

This only changes presentation order. It never changes which rows get selected (e.g. via sparring_n or anchors_mean), never touches scoring or tie-breaking, and sparring_report is identical either way, since it's an order-independent aggregate.

It exists to replace a manual post-hoc flip like:

result = result.sort_index(ascending=False).reset_index(drop=True)

which is fragile against how sample()'s own indexing/reset works. Use best_first=False instead when you want the worst-of-selection row first:

result, report = sample(
    df,
    bench_marks=bench_marks,
    custom_weights=custom_weights,
    best_first=False,
)

The sparring report

sample() doesn't just return the ranked frame, it returns a (df, sparring_report) tuple. sparring_report is a flat dict summarizing match quality, with one spar_<name> key per signal plus spar_min_score / spar_max_score / spar_mean_score, e.g.:

{
    "spar_age": 55.0, "spar_income": 57.5,   # one key per signal, avg score
    "spar_min_score": 0.0,
    "spar_max_score": 91.93,
    "spar_mean_score": 56.45,
}

Every value is on a 0-100 scale (100 = perfect match to benchmark) and rounded to 2 decimal places. This is the only place score information comes back to you: individual per-row scores are never returned, only these aggregates.

A signal with weight=0 is excluded from ranking/sorting entirely (it can never move the match score or break a tie), but it still gets its own spar_<name> entry in sparring_report, so you can track how close rows are on a signal without letting that signal influence which rows are considered "closest".

By default (sparring_n=None) both the returned df and the report cover all rows, unless anchors_mean is also set, in which case "all rows" means "all rows that survive the anchor trim," see Anchor trim below. Pass an int and sample() slices the sorted result down to just the top sparring_n rows (the ones closest to the benchmark) before anything else happens (including the anchor trim, if anchors_mean is also set). That slice is what you get back as df (further trimmed by anchors, if applicable), and it's also exactly what sparring_report and the score distribution are computed on. There's no separate "full set" kept around once sparring_n is set; if you need the rest of the rows too (e.g. to build the train set), take them from your original df yourself, or call sample() again with sparring_n=None.

Progress readout (show_progress)

When show_progress=True, the sparring report section of the printed readout marks each signal's average score, and the min / max / mean of the score distribution, with a traffic-light emoji:

  • 🔴 avg score in the bottom third (0-33.3)
  • 🟡 avg score in the middle third (33.3-66.7)
  • 🟢 avg score in the top third (66.7-100)
    age                  avg score= 64.33  🟡
    signup_date          avg score= 38.00  🟡
    country              avg score= 50.00  🟡

  SCORE DISTRIBUTION
  ------------------
    spar_min_score       =   6.67  🔴
    spar_max_score       =  82.00  🟢
    spar_mean_score      =  50.78  🟡

This is purely a print-time visual, it doesn't change anything about sparring_report's actual values.

If anchors_mean is set, show_progress=True also prints a small ANCHOR TRIM block before this, showing the anchors and condition used, the pool size going in, where (if anywhere) the walk stopped, and how many rows were kept.

Anchor trim (anchors_mean / anchors_condition)

sparring_n alone cuts by a fixed count, "give me the top 300 regardless of how they hold up as a group." anchors_mean is a different kind of cut: "keep taking the best-matching rows in order, but stop the moment the group, taken together, no longer meets a condition I care about." It's a running, group-level check, not a per-row one.

What it does

After sparring_n slicing (or over the full sorted result, if sparring_n=None), sample() walks the remaining rows in order, best match first. At each row it tentatively adds that row to an accepted set, recomputes a running statistic for every anchor over that accepted set (including the row just added), and checks whether the accepted set still satisfies anchors_condition. The first row that would break the condition is not added, and the walk stops right there, everything accepted before it is the final result. If every row passes, the whole pool is kept. If the very first row already fails, the result is an empty frame, there's no floor forcing at least one row through.

Because the walk always proceeds in order and stops for good on the first failure, sparring_n combined with anchors_mean behaves as an upper bound on how many rows the anchors get to consider, not a guarantee of how many rows come out. You can pass sparring_n=3000, anchors_mean=... and get anywhere from 0 to 3000 rows back, depending on where (if anywhere) the condition first breaks.

anchors_mean

A dict of {key: target}. Each key is resolved one of two ways:

  • A literal column name in df (e.g. "age"): the running mean of that raw column, on its own native scale. The target you give is compared on that same scale ("age": 30 means "don't let the running average age drop below 30").
  • A reserved alias, matching the same 0-100 scale sparring_report already uses:
    • "mean_score" / "spar_mean_score": running mean of the overall match score.
    • "min_score" / "spar_min_score": running min of the overall match score.
    • "max_score" / "spar_max_score": running max of the overall match score.
    • "spar_<signal_name>" (e.g. "spar_age"): running mean of that individual signal's own per-row score, not the raw column, the signal must already be a key in custom_weights.

Any key that isn't a real df column and doesn't match one of these aliases raises a ValueError during validation, before any scoring runs, the same way an unmatched custom_weights key does.

anchors_condition

Either "AND" or "OR" (case-insensitive), always required whenever anchors_mean is set:

  • "AND": the accepted set is considered failing only when every anchor is currently failing. As long as at least one anchor still holds up, the row is let through.
  • "OR": the accepted set is considered failing when any anchor is currently failing. All anchors have to hold up simultaneously for the row to be let through.

This generalizes to any number of anchors, one is fine, so is five.

Example

anchors_mean = {
    "age": 30,                  # running average age must stay >= 30
    "spar_mean_score": 50,      # running average match score (0-100) must stay >= 50
}
anchors_condition = "AND"       # stop only once BOTH are failing at once

result, report = sample(
    df,
    bench_marks=bench_marks,
    custom_weights=custom_weights,
    best_first=True,
    sparring_n=300,              # consider at most the top 300 matches
    anchors_mean=anchors_mean,
    anchors_condition=anchors_condition,
)

report here reflects whatever survived the trim, not the original 300-row pool, sparring_report and the score distribution are always computed on the final, post-trim set.

Requirements and edge cases

  • Requires drop_nan=True. Running means/mins/maxes over a pool that still has NaNs in it can't be trusted, so passing anchors_mean together with drop_nan=False raises a ValueError immediately, before scoring starts.
  • No floor. If the first row in the pool already fails the condition, the trim returns zero rows. This is intentional, the rule is the rule, there's no "keep at least one row" fallback.
  • sparring_n=None still works. With no sparring_n set, the anchor trim runs over the entire sorted df, there's no ceiling on how large the pool it walks through can be.
  • Fast at scale. Each anchor's running mean/min/max is computed once, up front, in a single pass over the pool, rather than being recalculated from scratch at every row. This matters once sparring_n (or the full pool) gets into the thousands, the trim stays fast instead of slowing down quadratically with pool size.

Generating custom_weights with slicer

Hand-picking numbers for custom_weights ({"country": 2, "signup_date": 1, "income": 1}) works fine for a handful of signals, but it gets arbitrary fast: why 2 and not 3? why does income get the same weight as signup_date? slicer exists to replace that guesswork with a principled cascade, so the weights you hand to sample() come from a deliberate hierarchy of "how much does this signal matter" instead of numbers picked by feel.

The philosophy (TS-DC: source / sub-primary / secondary)

slicer treats your signals as belonging to tiers, not a flat list:

  • source (the anchor): the primary feature (or set of features) the whole weighting is built around, e.g. country. "Source" covers the anchor broadly, who, what, or where the weighting originates from. Always present, always takes the entire pool to start, and it's the only tier that never decays.
  • temporal (the sub-primary, the WHEN): recency and/or seasonality, e.g. signup_date. Optional, and it eats into the pool the source started with.
  • causatives (the secondary, the WHY/WHAT): explanatory features that context the anchor further, e.g. income, channel. Optional, and they eat into whatever pool is left after temporal.

Each tier (other than source) takes a bite out of the remaining pool at a fixed decay rate, rather than the tiers splitting one fixed pot up front. That's the cascade: source_pool = 100 always (source never decays), temporal_pool = source_pool * decay only if temporal is used, causative_pool = last_pool * decay only if causatives are used, decaying from whichever pool was last actually assigned (so if temporal is skipped, causatives decay straight from source_pool, not from a temporal_pool that never existed).

The result is a flat {feature_name: weight} dict, on the same 0-100-ish scale sample() expects for custom_weights, ready to pass straight through.

Usage

from sparpartner import slicer

weights = slicer(
    source="country",
    recency="last_active_date",
    season="signup_month",
    causatives=["income", "channel"],
    decay_causatives=True,   # rank-based split: income > channel
    decay=0.5,
)
# {'country': 100, 'last_active_date': 33.33, 'signup_month': 16.67,
#  'income': 33.33, 'channel': 16.67}

result, sparring_report = sample(
    df,
    bench_marks=bench_marks,
    custom_weights=weights,
    best_first=True,
)

A few things worth knowing about how the tiers behave:

  • source accepts either a single feature name or a list of them. With a single name (a plain str, or a one-item list), that name simply takes the whole 100-point pool, same as before. With a list of 2 or more names, pool_source must be set explicitly, there's no default: pool_source=False gives every name in the list the full 100 independently (no sharing at all, each anchor stands on its own), pool_source=True treats the 100 as one shared pool, split evenly across every name in the list. There's no rank-based option for source, order never matters here, and source itself never decays either way, only how the 100 gets distributed changes.

  • temporal has two mutually exclusive modes. Either pass a single temporal="<feature>" (it takes the whole temporal pool), or pass recency= and season= together (never temporal with either of them, and never just one of recency/season). When both are given, recency is always rank 0 (the larger share) and season is always rank 1 (the smaller share), split by the same decay rate as everything else, not a separately tunable percentage.

  • causatives accepts either a single feature name or a list of any length. A plain str is treated the same as a one-item list. There's no cap on how many you can pass, causative_pool is a fixed quota (last_pool * decay) no matter how many names split it, more causatives just means each one gets a thinner slice of that same quota. How multiple causatives split depends on decay_causatives, which has no default and must be set explicitly whenever causatives resolves to 2 or more items:

    • False splits evenly, order doesn't matter.
    • True splits by rank, first item gets the most, decaying all the way down the list at the same decay rate as everything else.
    • a positive int N is a hybrid: only the first N causatives (by list order) get rank-decayed against each other, the way True would rank them, then whatever's left of causative_pool after that is split evenly across the rest. Handy when you want the top few causatives to dominate but don't want a long geometric tail thinning out every remaining one. If N is greater than or equal to how many causatives you passed, this behaves identically to True, there's no tail left to flatten.

    With exactly one causative, decay_causatives is ignored entirely, there's no rank to decay across a single item, it gets the whole causative pool either way.

  • decay is the single dial controlling every split in the cascade: the temporal/causative pool sizes, the recency/season split, the source split (if pool_source=True), and the causatives split whenever decay_causatives is True or an int. As decay approaches 1, splits flatten toward even; as it approaches 0, the earlier-ranked item dominates. source is the one exception, it never decays regardless of decay.

  • bystanders are along for the ride only. Names passed here show up in the returned dict at weight 0, taking no part in any split or decay math. This is the same shape sample() already accepts, a custom_weights entry with weight=0, which per sample()'s own contract still generates a spar_<name> entry in sparring_report, so you can pass a slicer bystander straight into sample() to track how close rows are on it, without letting it influence which rows are ranked closest.

  • Every feature name must be unique across all groups (source, temporal/recency/season, causatives, bystanders); reusing a name across two groups raises a ValueError, and comma-joined strings passed instead of a real list (source/causatives/ bystanders) are rejected for the same reason sample() rejects them.

Usage examples

slicer can be called a lot of different ways depending on how many tiers you actually need. A single source on its own is a valid call, every other tier is optional and only shows up in the result if you pass it.

1. Simplest call, source only

weights = slicer(source="country")
# {'country': 100}

2. source + temporal as one combined feature

weights = slicer(source="country", temporal="signup_recency_blend")
# {'country': 100, 'signup_recency_blend': 50.0}   # decay=0.5 default

3. source + recency/season split instead of one combined temporal

weights = slicer(
    source="country",
    recency="days_since_signup",
    season="signup_month",
)
# {'country': 100,
#  'days_since_signup': 33.33,
#  'signup_month': 16.67}

4. Full cascade: source -> temporal -> causatives, rank-decayed

weights = slicer(
    source="country",
    recency="days_since_signup",
    season="signup_month",
    causatives=["income", "channel", "referral_source"],
    decay_causatives=True,
)
# {'country': 100,
#  'days_since_signup': 33.33, 'signup_month': 16.67,
#  'income': 14.29, 'channel': 7.14, 'referral_source': 3.57}

5. Same cascade, causatives split evenly instead of by rank

weights = slicer(
    source="country",
    temporal="signup_recency_blend",
    causatives=["income", "channel"],
    decay_causatives=False,
)
# {'country': 100, 'signup_recency_blend': 50.0,
#  'income': 12.5, 'channel': 12.5}

5b. Hybrid split, decay the top 2 causatives, flatten the rest

weights = slicer(
    source="country",
    temporal="recency_blend",
    causatives=["income", "channel", "referral_source", "device_type", "region"],
    decay_causatives=2,
)
# causative_pool here = 25 (100 * 0.5 * 0.5)
# {'country': 100, 'recency_blend': 50.0,
#  'income': 12.9, 'channel': 6.45,
#  'referral_source': 1.88, 'device_type': 1.88, 'region': 1.88}
# income/channel are rank-decayed against each other same as True
# would rank them; whatever's left of the 25-pool after that (~5.65)
# is split evenly across the remaining 3 causatives instead of
# continuing to decay them into a long, thinning tail

6. A single causatives feature, passed as a plain str

weights = slicer(source="country", causatives="income")
# {'country': 100, 'income': 50.0}
# decay_causatives isn't required here, there's only one item to rank

7. Multi-source, pool_source=False, each source keeps the full pool

weights = slicer(source=["home_team", "away_team"], pool_source=False)
# {'home_team': 100, 'away_team': 100}

8. Multi-source, pool_source=True, the pool is shared evenly

weights = slicer(source=["home_team", "away_team"], pool_source=True)
# {'home_team': 50.0, 'away_team': 50.0}

9. Multi-source (3 names), pooled, plus the full temporal + causative cascade

weights = slicer(
    source=["home_team", "away_team", "referee"],
    pool_source=True,
    recency="days_since_last_match",
    season="season_stage",
    causatives=["xg_diff", "possession_pct"],
    decay_causatives=True,
)
# {'home_team': 33.33, 'away_team': 33.33, 'referee': 33.33,
#  'days_since_last_match': 33.33, 'season_stage': 16.67,
#  'xg_diff': 16.67, 'possession_pct': 8.33}

10. With bystanders, reported at weight 0, no effect on the cascade

weights = slicer(
    source="country",
    causatives="income",
    bystanders=["signup_channel", "referral_code"],
)
# {'country': 100, 'income': 50.0,
#  'signup_channel': 0, 'referral_code': 0}

11. Custom decay rate, steeper vs flatter cascade

weights_steep = slicer(source="country", temporal="recency_blend", decay=0.3)
# {'country': 100, 'recency_blend': 30.0}

weights_flat = slicer(source="country", temporal="recency_blend", decay=0.8)
# {'country': 100, 'recency_blend': 80.0}

A note on pool_source, when it's required and when it's ignored

pool_source only matters once source is a list of 2 or more names, that's the only situation where it must be set explicitly (True or False, no default). If source is a plain str, or a list with just one name in it, pool_source is ignored entirely, that single name always takes the full pool regardless of what (or whether) pool_source is set:

# pool_source omitted, source is a single str, no error
slicer(source="country")
# {'country': 100}

# pool_source omitted, source is a one-item list, still no error
slicer(source=["country"])
# {'country': 100}

# pool_source omitted, source has 2+ names, this raises
slicer(source=["home_team", "away_team"])
# ValueError: pool_source must be explicitly set to True or False when
# source is a list of more than one feature name. there is no default

# pool_source now provided, works fine
slicer(source=["home_team", "away_team"], pool_source=True)
# {'home_team': 50.0, 'away_team': 50.0}

Usage

Sample usage

df is the only argument you can pass positionally. Every other argument, including bench_marks, custom_weights, and best_first, must be passed by keyword (see Keyword-only arguments below).

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, sparring_report = sample(
    df,
    bench_marks=bench_marks,
    custom_weights=custom_weights,
    best_first=True,      # required, no default, True = best match first; False = worst-of-selection first
    sparring_n=None,      # None = every row scored, sorted, and returned
    drop_nan=True,
    show_progress=True,   # prints the full scoring breakdown
)

print(result)
print(sparring_report)

age=30, signup_date="2023-01-01", and country="US" closely match row id=1 (age 25, close date, US), so that row lands at or near the top of the sorted output (or the bottom, if best_first=False). The country column here is the string exact-match path in action: rows 1, 2, and 4 score 1.0 against bench "US", rows 3 and 5 ("CA", "MX") score 0.0, see String scoring, in detail above for the full walkthrough.

# --- post-sample: turn the ranking into an actual train/test split ---
# Use best_first=False: worst-of-selection first, best match (closest to
# benchmark) last. That puts the lookalike rows in one contiguous block
# at the tail, so the split is just a slice off the end, no need to
# track which end is which.
ranked, _ = sample(
    df,
    bench_marks=bench_marks,
    custom_weights=custom_weights,
    best_first=False,
)

# Slice however large you want the test set to be, e.g. the top 30%:
cut = int(len(ranked) * 0.3)
test = ranked.iloc[-cut:]    # lookalikes, the harder, honest test set
train = ranked.iloc[:-cut]   # everything unlike the benchmark

Parameters

Name Type Default What it does
df DataFrame required, positional Must contain a column for every name (key) in custom_weights. No restriction on your own column names, spar-prefixed columns are fine
bench_marks dict None, but required (raises if left None) {column_name: benchmark_value}. Keyword-only
custom_weights dict of {name: weight} None, but required (raises if left None) Which columns to score and how much each contributes. Keyword-only
best_first bool None, but required (raises if left None) Applied last, after everything else (including sparring_n slicing and the anchor trim). True = best match first. False = flips that same set of rows to worst-of-selection first, best last. Never changes which rows are selected; see Row order. Keyword-only
sparring_n int or None None None = every row scored, sorted, and returned (or, if anchors_mean is set, every row that survives the trim). An int slices the sorted result down to the top sparring_n rows before anything else, including the anchor trim. That slice (further trimmed by anchors, if applicable) is what's returned as df, and what the report/distribution are computed on. Keyword-only
drop_nan bool True If True, drops any row with a NaN in a per-signal working score or in the overall match score, after printing (if show_progress) a sanity check of what was dropped and why. Must be True if anchors_mean is set, see Anchor trim. Keyword-only
anchors_mean dict or None None {key: target}, keys are either a real df column name (compared as a running mean on its native scale) or a reserved alias (mean_score/min_score/max_score/spar_<signal>, compared as a running mean/min/max on the 0-100 score scale). Applied after sparring_n slicing. See Anchor trim. Keyword-only
anchors_condition "AND" or "OR" (case-insensitive) None, required if anchors_mean is set "AND" stops only once every anchor is failing at once. "OR" stops the moment any single anchor is failing. See Anchor trim. Keyword-only
show_progress bool False Prints a full readout, in run order: header (including a signals used count), per-column type/cap/sample scores, drop_nan check (if drop_nan=True), normalize check, sort-apply readout, sparring_n slice readout (if sparring_n is set), anchor trim readout (if anchors_mean is set), top N ranked rows with weighted contributions (N in the header always matches the number of rows actually shown), the sparring report itself with 🔴/🟡/🟢 markers next to each score flagging any signal contributing zero separation, and finally a best_first flip readout (only printed if best_first=False). Keyword-only

Returns

sample() returns a (df, sparring_report) tuple, not just a DataFrame. The df always contains only your original columns (reordered/sliced), no score columns are ever attached to it. See The sparring report for how score information comes back to you instead.

Validation

Input validation runs upfront, before any scoring starts, in three passes: general parameter checks, custom_weights checks, then anchors_mean/anchors_condition checks (only if anchors_mean is set).

Raises TypeError if:

  • df isn't a pandas DataFrame
  • bench_marks isn't a dict
  • custom_weights isn't a dict
  • drop_nan, show_progress, or best_first isn't a bool
  • sparring_n isn't an int or None (bools are rejected too)
  • anchors_mean isn't a dict (when it isn't None)

Raises ValueError if:

  • bench_marks is left as None (its default)
  • custom_weights is left as None (its default)
  • best_first is left as None (its default)
  • df has no rows
  • custom_weights is an empty dict
  • sparring_n isn't a positive integer
  • a custom_weights key isn't a string, or doesn't match a column in df
  • a custom_weights key has no matching entry in bench_marks
  • a custom_weights key is literally named "score". sample() keeps its own overall-total working score internally, and a signal named "score" would generate the exact same internal name, corrupting that total instead of just shadowing a per-signal value. Rename that column in df (and its entries in bench_marks/custom_weights) before calling sample(). Names that merely contain "score", like test_score or score_pct, are unaffected, only an exact match on "score" collides
  • 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, inf, or -inf
  • anchors_mean is an empty dict
  • anchors_mean is set while drop_nan=False
  • anchors_condition is left as None while anchors_mean is set, or is a string other than "AND"/"OR" (case-insensitive)
  • an anchors_mean key isn't a string
  • an anchors_mean key doesn't match a real df column and doesn't match a reserved alias (mean_score/min_score/max_score/ spar_<signal>, where <signal> must be a key already present in custom_weights)
  • an anchors_mean key resolves to a real df column that isn't numeric
  • an anchors_mean target isn't a finite real number

Note on duplicate signal names: since custom_weights is now a dict, keys are inherently unique, so a repeated column name can no longer be passed in the first place, Python itself resolves a repeated key in a dict literal (keeping only the last value) before sample() ever sees it. There's nothing left for validation to catch here.

A couple of things worth knowing

  • Your own column names are unrestricted: sample() computes its working scores under internally-generated names that can't collide with anything you'd realistically name a column, and those working columns are always dropped before the df is returned. You can freely have your own columns named spar_score, spar_age, or anything else, sample() won't touch, rename, or overwrite them.
  • 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.
  • Keyword-only arguments: df is the only argument sample() accepts positionally. Every other argument, bench_marks, custom_weights, best_first, sparring_n, drop_nan, anchors_mean, anchors_condition, and show_progress, must be passed by name. This is enforced by Python itself: a positional call like sample(df, weights, benchmarks) fails immediately with a TypeError, before any of sample()'s own code runs. It exists specifically to rule out accidentally swapping bench_marks and custom_weights, which are both dicts and can't be told apart by type alone. bench_marks, custom_weights, and best_first additionally default to None but are not actually optional, leaving any of them out (or passing None explicitly) raises a ValueError naming exactly which one is missing.

Metadata

Release files for sparpartner 3.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for sparpartner 3.0.0
File Size Uploaded Provenance
sparpartner-3.0.0.tar.gz 56.0 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for sparpartner 3.0.0
File Interpreter ABI Platform
sparpartner-3.0.0-py3-none-any.whl Python 3 none any Details

Total release size: 85.5 kB

Release files / sparpartner-3.0.0.tar.gz

Download URL sparpartner-3.0.0.tar.gz
Size 56.0 kB
Tags Source
SHA-256 checksum
How to use checksums
d999b24a0238a16329215f91ca4651a1f5e651a4395ac15225baff106b343a9e
BLAKE2b-256 checksum
How to use checksums
902bc65fceda55380756e4f36d49fc3dc73e3b3ca6d83b14182e408404de68da
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.1

Release files / sparpartner-3.0.0-py3-none-any.whl

Download URL sparpartner-3.0.0-py3-none-any.whl
Size 29.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
493c58ccc009b93fad62291b5f164761402b78a39b6d897f30d76bacf0b439c4
BLAKE2b-256 checksum
How to use checksums
d0aba117fe35d99ae4820fcb6a659231152f081d7b5b72ea26b77911134b3e8d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.1

Security for sparpartner 3.0.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.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page