| """Programmatic synthesizer for the collections-treatment surface. |
| |
| Produces `(customer_history, context_idx, context_text, treatment_labels, |
| attribution_labels, reasoning_text)` examples. Like the dispute |
| synthesizer, this reuses the parent's existing customer histories |
| (`data/synthetic/token_ids.npy`) — no fresh transaction generation. |
| |
| Task (per ADR 0003 + blueprint §5): |
| |
| Given a customer's 64-transaction history at the point of |
| delinquency, output the probability the customer responds |
| favorably to each of K=4 treatments: |
| |
| treatment 0 = settlement (one-time reduced lump sum) |
| treatment 1 = payment_plan (3-6 month installments) |
| treatment 2 = soft_touch (light contact, no offer yet) |
| treatment 3 = no_offer (write-off; don't burn analyst hours) |
| |
| Each treatment is rated on a 3-band categorical: |
| |
| band 0 = unlikely_respond |
| band 1 = ambiguous |
| band 2 = likely_respond |
| |
| The model output is a (K=4) × (bands=3) softmax distribution. |
| |
| Cross-position signals (mirror-able in TransactionEncoder): |
| |
| - `recent_velocity_score` mean days_since_last across the last 16 |
| transactions; high = dormant. |
| - `subscription_burden_score` count of `is_recurring=1` transactions; |
| high = recurring obligations, plan-tolerant. |
| - `merchant_diversity_score` unique merchant_id count; high = |
| discretionary breadth. |
| - `large_amount_score` count of large-amount transactions; high = |
| discretionary capacity (settlement-able). |
| - `spending_volatility_score` std of amount tokens; high = bouncy. |
| |
| These signals are computed at corpus generation time (numpy) and at |
| inference time (torch in the encoder — see §encoder markers in the |
| blueprint). |
| |
| Doctrine compliance (data-distribution-doctrine §6): |
| |
| - Per-treatment class balance: aim for each of the 12 (treatment, band) |
| cells to receive ≥ 10% of the corpus. We sample treatment-balanced |
| rather than label-balanced because no single treatment carries the |
| headline signal. |
| - Hard negatives: ~10% adversarial examples where one signal |
| contradicts the dominant pattern (high large_amt but very dormant, |
| high subscription burden but high volatility, etc.). |
| - Length distribution: context_text p10=15 / p50=35 / p90=80 words. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| import random |
| from dataclasses import asdict, dataclass |
| from pathlib import Path |
| from typing import Any |
|
|
| import numpy as np |
|
|
|
|
| |
| FEATURE_DAYS_SINCE_LAST = 2 |
| FEATURE_IS_RECURRING = 3 |
| FEATURE_MERCHANT_ID = 5 |
| FEATURE_AMOUNT = 8 |
| FEATURE_CUSTOMER_TENURE = 14 |
|
|
| |
| RESERVED_OFFSET = 3 |
|
|
| |
| IS_RECURRING_TRUE = RESERVED_OFFSET + 1 |
|
|
| |
| |
| |
| |
| |
| AMOUNT_LARGE_THRESH = RESERVED_OFFSET + 150 |
|
|
| |
| RECENT_WINDOW = 16 |
|
|
| |
| |
| |
| CONTEXT_IDX_DEFAULT = 63 |
|
|
| |
| TREATMENT_SETTLEMENT = 0 |
| TREATMENT_PAYMENT_PLAN = 1 |
| TREATMENT_SOFT_TOUCH = 2 |
| TREATMENT_NO_OFFER = 3 |
| NUM_TREATMENTS = 4 |
| TREATMENT_NAMES = ["settlement", "payment_plan", "soft_touch", "no_offer"] |
|
|
| |
| BAND_UNLIKELY = 0 |
| BAND_AMBIGUOUS = 1 |
| BAND_LIKELY = 2 |
| NUM_BANDS = 3 |
| BAND_NAMES = ["unlikely_respond", "ambiguous", "likely_respond"] |
|
|
|
|
| |
| |
| |
| |
| |
|
|
|
|
| def signal_recent_velocity(history: np.ndarray) -> float: |
| """Mean days_since_last across the last RECENT_WINDOW transactions. |
| |
| High value = the customer's recent activity is sparse (dormant). |
| Low value = active recent activity. |
| |
| The `days_since_last` feature is bucketed [0, 365] → 30 bins, then |
| +3 for reserved tokens. We use the raw token mean as the velocity |
| proxy; higher token = more days between transactions = more dormant. |
| """ |
| return float(history[-RECENT_WINDOW:, FEATURE_DAYS_SINCE_LAST].mean()) |
|
|
|
|
| def signal_subscription_burden(history: np.ndarray) -> int: |
| """Count of `is_recurring == 1` transactions in the history. |
| |
| Customers with many recurring obligations already tolerate |
| auto-debits → payment plans are realistic. |
| """ |
| return int(np.sum(history[:, FEATURE_IS_RECURRING] == IS_RECURRING_TRUE)) |
|
|
|
|
| def signal_merchant_diversity(history: np.ndarray) -> int: |
| """Number of unique merchant_id values in the history. |
| |
| High diversity = discretionary breadth (entertainment, travel, |
| dining). Low diversity = subsistence (groceries, gas, utilities). |
| """ |
| return int(np.unique(history[:, FEATURE_MERCHANT_ID]).size) |
|
|
|
|
| def signal_large_amount_count(history: np.ndarray) -> int: |
| """Count of large-amount transactions (token >= AMOUNT_LARGE_THRESH).""" |
| return int(np.sum(history[:, FEATURE_AMOUNT] >= AMOUNT_LARGE_THRESH)) |
|
|
|
|
| def signal_spending_volatility(history: np.ndarray) -> float: |
| """Standard deviation of amount tokens across the full history.""" |
| return float(history[:, FEATURE_AMOUNT].astype(np.float32).std()) |
|
|
|
|
| def signal_customer_tenure(history: np.ndarray) -> int: |
| """Customer tenure bucket at the most-recent transaction (token id).""" |
| return int(history[-1, FEATURE_CUSTOMER_TENURE]) |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| def classify_settlement(history: np.ndarray) -> int: |
| """Likelihood the customer responds to a settlement offer. |
| |
| Settlement requires: discretionary capacity (can muster lump sum) + |
| stable behavior (won't bail mid-negotiation) + active engagement. |
| """ |
| large_amt = signal_large_amount_count(history) |
| velocity = signal_recent_velocity(history) |
| volatility = signal_spending_volatility(history) |
|
|
| if large_amt >= 12 and velocity <= 8.0 and volatility <= 60.0: |
| return BAND_LIKELY |
| if large_amt >= 5 and velocity <= 11.0: |
| return BAND_AMBIGUOUS |
| return BAND_UNLIKELY |
|
|
|
|
| def classify_payment_plan(history: np.ndarray) -> int: |
| """Likelihood the customer accepts a payment plan. |
| |
| Plans work for customers with recurring obligations (they already |
| tolerate auto-debits) and reasonably active recent behavior. |
| """ |
| sub_burden = signal_subscription_burden(history) |
| velocity = signal_recent_velocity(history) |
|
|
| if sub_burden >= 14 and velocity <= 10.0: |
| return BAND_LIKELY |
| if sub_burden >= 6 and velocity <= 13.0: |
| return BAND_AMBIGUOUS |
| return BAND_UNLIKELY |
|
|
|
|
| def classify_soft_touch(history: np.ndarray) -> int: |
| """Likelihood the customer self-resolves with a light contact. |
| |
| Soft-touch works for diversified, moderately active spenders whose |
| delinquency is more likely a temporary cash crunch than a structural |
| problem. |
| """ |
| unique_merch = signal_merchant_diversity(history) |
| velocity = signal_recent_velocity(history) |
| volatility = signal_spending_volatility(history) |
|
|
| if ( |
| unique_merch >= 17 |
| and velocity <= 10.0 |
| and 30.0 <= volatility <= 70.0 |
| ): |
| return BAND_LIKELY |
| if unique_merch >= 12 and velocity <= 13.0: |
| return BAND_AMBIGUOUS |
| return BAND_UNLIKELY |
|
|
|
|
| def classify_no_offer(history: np.ndarray) -> int: |
| """Likelihood that no-offer (write-off) is the right answer. |
| |
| No-offer is "right" when the behavioral signature suggests the |
| customer is dormant + subsistence-only: sparse recent activity, |
| low merchant diversity, no large-amount transactions. The bank |
| saves analyst hours by not pursuing. |
| """ |
| velocity = signal_recent_velocity(history) |
| unique_merch = signal_merchant_diversity(history) |
| large_amt = signal_large_amount_count(history) |
|
|
| if velocity >= 11.0 and unique_merch <= 12 and large_amt <= 4: |
| return BAND_LIKELY |
| if velocity >= 9.5 and large_amt <= 6: |
| return BAND_AMBIGUOUS |
| return BAND_UNLIKELY |
|
|
|
|
| def classify_all_treatments(history: np.ndarray) -> list[int]: |
| """Per-treatment labels in the canonical order. |
| |
| Returns: |
| list[int] of length NUM_TREATMENTS = 4, ordered |
| [settlement, payment_plan, soft_touch, no_offer], each in |
| {BAND_UNLIKELY, BAND_AMBIGUOUS, BAND_LIKELY}. |
| """ |
| return [ |
| classify_settlement(history), |
| classify_payment_plan(history), |
| classify_soft_touch(history), |
| classify_no_offer(history), |
| ] |
|
|
|
|
| def dominant_treatment(treatment_labels: list[int]) -> int: |
| """Return the treatment index with the highest band. |
| |
| Tie-break order: settlement > payment_plan > soft_touch > no_offer. |
| The order reflects "lighter intervention preferred when tied" — |
| i.e., the analyst would prefer a settlement to a plan when both |
| score the same. |
| """ |
| best = 0 |
| best_band = treatment_labels[0] |
| for i in range(1, NUM_TREATMENTS): |
| if treatment_labels[i] > best_band: |
| best = i |
| best_band = treatment_labels[i] |
| return best |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| def attribution_for_treatment( |
| history: np.ndarray, |
| treatment_idx: int, |
| ) -> np.ndarray: |
| """Per-position attribution for the specified treatment.""" |
| attr = np.zeros(64, dtype=np.float32) |
| if treatment_idx == TREATMENT_SETTLEMENT: |
| |
| mask = history[:, FEATURE_AMOUNT] >= AMOUNT_LARGE_THRESH |
| attr[mask] = 1.0 |
| elif treatment_idx == TREATMENT_PAYMENT_PLAN: |
| |
| mask = history[:, FEATURE_IS_RECURRING] == IS_RECURRING_TRUE |
| attr[mask] = 1.0 |
| elif treatment_idx == TREATMENT_SOFT_TOUCH: |
| |
| |
| merchants = history[:, FEATURE_MERCHANT_ID] |
| unique_vals, counts = np.unique(merchants, return_counts=True) |
| singletons = unique_vals[counts == 1] |
| mask = np.isin(merchants, singletons) |
| attr[mask] = 1.0 |
| elif treatment_idx == TREATMENT_NO_OFFER: |
| |
| |
| recent = np.arange(48, 64) |
| dsl = history[recent, FEATURE_DAYS_SINCE_LAST] |
| |
| median_dsl = float(np.median(history[:, FEATURE_DAYS_SINCE_LAST])) |
| for pos in recent: |
| if float(history[pos, FEATURE_DAYS_SINCE_LAST]) >= median_dsl: |
| attr[pos] = 1.0 |
| |
| attr[CONTEXT_IDX_DEFAULT] = max(attr[CONTEXT_IDX_DEFAULT], 1.0) |
| return attr |
|
|
|
|
| |
| |
| |
| |
|
|
| CONTEXT_TEMPLATES: dict[str, list[str]] = { |
| "formal": [ |
| "Customer is {dpd} days past due with an outstanding balance of ${balance:,}. Please assess treatment options.", |
| "Account flagged for collections review. {dpd} dpd, ${balance:,} outstanding. Recommend an appropriate treatment from the available catalog.", |
| "Delinquency notice: {dpd} days past due, balance ${balance:,}. Requesting model-recommended collections action.", |
| ], |
| "casual": [ |
| "Hey — this customer is {dpd} days behind, ${balance:,} owed. What's the right treatment?", |
| "Account at {dpd} dpd, ${balance:,} on the books. Treatment recommendation?", |
| "Customer is past due {dpd} days for ${balance:,}. Best path forward?", |
| ], |
| "terse": [ |
| "{dpd}dpd ${balance:,}. Treatment?", |
| "Past due {dpd}d, ${balance:,}.", |
| "Collections review. {dpd}/${balance:,}.", |
| ], |
| "detailed": [ |
| "This customer is {dpd} days past due on a ${balance:,} balance. Last successful payment was {last_pay} days ago. Account opened {tenure} months ago. Recommend treatment.", |
| "Delinquency summary: {dpd} dpd, ${balance:,} outstanding, last payment {last_pay} days back. Customer tenure {tenure} months. What's the model's call?", |
| ], |
| "urgent": [ |
| "FYI — this account is {dpd} days past due, ${balance:,} outstanding. Need a treatment call before EOD.", |
| "Time-sensitive: {dpd}dpd, ${balance:,} balance. Pre-charge-off window closes soon. Treatment?", |
| ], |
| } |
|
|
|
|
| def _build_context_text( |
| history: np.ndarray, |
| rng: random.Random, |
| ) -> tuple[str, str, dict[str, int]]: |
| """Render a delinquency context string for the analyst. |
| |
| Returns (text, tone, vars) where vars captures the dpd/balance/etc. |
| used in the template for downstream auditing. |
| """ |
| |
| |
| |
| |
| |
| |
| |
| |
| dpd = rng.randint(30, 180) |
| balance = rng.randint(500, 25000) |
| last_pay = rng.randint(20, 180) |
| tenure = max(1, signal_customer_tenure(history) - RESERVED_OFFSET) * 12 |
|
|
| tone = rng.choice(list(CONTEXT_TEMPLATES.keys())) |
| template = rng.choice(CONTEXT_TEMPLATES[tone]) |
| text = template.format( |
| dpd=dpd, |
| balance=balance, |
| last_pay=last_pay, |
| tenure=tenure, |
| ) |
| return text, tone, { |
| "dpd": dpd, |
| "balance": balance, |
| "last_pay": last_pay, |
| "tenure_months": tenure, |
| } |
|
|
|
|
| |
|
|
|
|
| def _decode_treatment_name(treatment_idx: int) -> str: |
| return TREATMENT_NAMES[treatment_idx] |
|
|
|
|
| def _decode_band_name(band: int) -> str: |
| return BAND_NAMES[band] |
|
|
|
|
| def build_reasoning_text( |
| history: np.ndarray, |
| treatment_labels: list[int], |
| ) -> str: |
| """Programmatic reasoning grounded in the cross-position signals. |
| |
| The text is fully deterministic from `treatment_labels` + history. |
| Following lesson 4, this is NOT generated by the LM head; the model |
| renders it from its (per-treatment band + ground-truth signals) |
| output at inference time. |
| """ |
| dom_idx = dominant_treatment(treatment_labels) |
| dom_band = treatment_labels[dom_idx] |
| dom_name = _decode_treatment_name(dom_idx) |
| velocity = signal_recent_velocity(history) |
| sub_burden = signal_subscription_burden(history) |
| unique_merch = signal_merchant_diversity(history) |
| large_amt = signal_large_amount_count(history) |
| volatility = signal_spending_volatility(history) |
|
|
| parts: list[str] = [] |
| parts.append( |
| f"Recommended treatment: {dom_name} (band: {_decode_band_name(dom_band)})." |
| ) |
| parts.append( |
| f"Behavioral signature — recent velocity {velocity:.1f} " |
| f"(lower=more active), subscription burden {sub_burden}, " |
| f"merchant diversity {unique_merch}, large-amount transactions {large_amt}, " |
| f"spending volatility {volatility:.1f}." |
| ) |
| if dom_idx == TREATMENT_SETTLEMENT: |
| parts.append( |
| f"Settlement is the dominant option because the customer has " |
| f"{large_amt} discretionary-band transactions and stable spending " |
| f"behavior — consistent with the capacity to muster a lump sum." |
| ) |
| elif dom_idx == TREATMENT_PAYMENT_PLAN: |
| parts.append( |
| f"Payment plan is the dominant option because the customer " |
| f"already maintains {sub_burden} recurring obligations — " |
| f"a structured monthly debit aligns with their existing pattern." |
| ) |
| elif dom_idx == TREATMENT_SOFT_TOUCH: |
| parts.append( |
| f"Soft-touch is the dominant option because the customer " |
| f"shows {unique_merch} unique merchants and active recent " |
| f"behavior — pattern suggests temporary cash crunch, not " |
| f"structural distress." |
| ) |
| elif dom_idx == TREATMENT_NO_OFFER: |
| parts.append( |
| f"No-offer is the dominant option because the recent activity " |
| f"pattern is sparse (velocity {velocity:.1f}) and the merchant " |
| f"diversity is low — behavioral signature suggests the customer " |
| f"is dormant. Continued outreach is unlikely to convert." |
| ) |
| return " ".join(parts) |
|
|
|
|
| |
|
|
|
|
| @dataclass |
| class CollectionsExample: |
| """One synthesized collections-treatment training example.""" |
|
|
| customer_idx: int |
| context_idx: int |
| context_text: str |
| treatment_labels: list[int] |
| attribution_labels: list[float] |
| reasoning_text: str |
| tone: str |
| is_adversarial: bool |
| context_vars: dict[str, int] |
|
|
| def to_dict(self) -> dict[str, Any]: |
| return asdict(self) |
|
|
|
|
| def synthesize_one( |
| history: np.ndarray, |
| customer_idx: int, |
| rng: random.Random, |
| adversarial: bool = False, |
| ) -> CollectionsExample: |
| """Produce one collections example for a given customer history. |
| |
| The label is fully determined by `history`; the context_text is |
| rendered with rng-driven tone variation. Adversarial flag adds |
| surface-form perturbation to the context text (lesson 3 boundary |
| coverage) but does NOT change the label. |
| """ |
| treatment_labels = classify_all_treatments(history) |
| dom_idx = dominant_treatment(treatment_labels) |
| attribution = attribution_for_treatment(history, dom_idx) |
| context_text, tone, context_vars = _build_context_text(history, rng) |
| if adversarial: |
| context_text = _apply_adversarial_perturbation(context_text, rng) |
| reasoning = build_reasoning_text(history, treatment_labels) |
| return CollectionsExample( |
| customer_idx=customer_idx, |
| context_idx=CONTEXT_IDX_DEFAULT, |
| context_text=context_text, |
| treatment_labels=treatment_labels, |
| attribution_labels=attribution.tolist(), |
| reasoning_text=reasoning, |
| tone=tone, |
| is_adversarial=adversarial, |
| context_vars=context_vars, |
| ) |
|
|
|
|
| def _apply_adversarial_perturbation(text: str, rng: random.Random) -> str: |
| """Same shape as the dispute synthesizer's perturbation: light case-flip, |
| occasional double-space. Preserves readability while training the |
| model against surface-form noise (doctrine §3 production drift).""" |
| chars = list(text) |
| for i in range(len(chars)): |
| if chars[i].isalpha() and rng.random() < 0.10: |
| chars[i] = chars[i].swapcase() |
| if rng.random() < 0.3 and " " in text: |
| idx = text.index(" ") |
| chars.insert(idx, " ") |
| return "".join(chars) |
|
|
|
|
| |
|
|
|
|
| def generate_corpus( |
| histories: np.ndarray, |
| train_indices: np.ndarray, |
| target_size: int = 4000, |
| seed: int = 42, |
| adversarial_fraction: float = 0.10, |
| ) -> list[CollectionsExample]: |
| """Generate a class-balanced collections corpus. |
| |
| Sampling strategy: we draw customers uniformly from train_indices, |
| classify, and accept up to a per-(dominant_treatment) cap so the |
| final corpus has reasonable balance across the four treatments. |
| |
| This is intentionally less hand-tuned than the dispute synthesizer |
| because the rule has 4 treatments × 3 bands = 12 cells; trying to |
| target each cell is overkill at 4000 examples. The cast curator |
| handles per-pattern coverage separately. |
| |
| Args: |
| histories: (N, 64, 15) full corpus. |
| train_indices: indices into histories for the training pool. |
| target_size: total corpus size. |
| seed: RNG seed. |
| adversarial_fraction: fraction of examples with text perturbation. |
| |
| Returns: |
| List of CollectionsExample, length ~target_size. |
| """ |
| rng = random.Random(seed) |
| np_rng = np.random.RandomState(seed) |
| examples: list[CollectionsExample] = [] |
|
|
| |
| |
| per_treatment_cap = target_size // 2 |
| treatment_counts = [0] * NUM_TREATMENTS |
|
|
| n_adv_target = int(target_size * adversarial_fraction) |
| n_adv = 0 |
| attempts = 0 |
| max_attempts = target_size * 8 |
|
|
| while len(examples) < target_size and attempts < max_attempts: |
| attempts += 1 |
| customer_idx = int(np_rng.choice(train_indices)) |
| history = histories[customer_idx] |
| treatment_labels = classify_all_treatments(history) |
| dom_idx = dominant_treatment(treatment_labels) |
| if treatment_counts[dom_idx] >= per_treatment_cap: |
| continue |
| adversarial = (n_adv < n_adv_target) and (rng.random() < 0.15) |
| example = synthesize_one( |
| history, customer_idx, rng, adversarial=adversarial, |
| ) |
| examples.append(example) |
| treatment_counts[dom_idx] += 1 |
| if adversarial: |
| n_adv += 1 |
|
|
| rng.shuffle(examples) |
| return examples |
|
|
|
|
| def write_jsonl( |
| examples: list[CollectionsExample], |
| output_path: Path | str, |
| ) -> None: |
| """Write the corpus to JSONL, one example per line.""" |
| output_path = Path(output_path) |
| with output_path.open("w") as f: |
| for example in examples: |
| f.write(json.dumps(example.to_dict()) + "\n") |
|
|