Payback Predictor V2
This page documents the payback predictor v2 model integration in pkg/underwriting_model — a
separate mechanism from the ML rules documented in Rules
Reference (RuleMLPaybackPrediction, RuleMLPaybackPredictionVariableThreshold), which run
inside the rule engine as ordinary rulebook rules. Payback predictor v2 instead runs after the
rule engine has already produced a decision, as a final step in the API Lambda, and can override
that decision in either direction.
Purpose
The model predicts a risk score for a float request and, once fully rolled out for a user, can approve floats the rule engine denied or deny floats the rule engine approved — a second opinion layered on top of the rulebook-driven decision described in Rule Engine.
Where It Runs
Unlike the Rule Runner / Result Runner pipeline, payback predictor v2 is invoked synchronously
inside the API Lambda, from a single choke point (Predictor.getEvaluationResult in
pkg/api/float_check.go) shared by all five eligibility/loan endpoints:
-
FloatRequest(POSTfloat draw) -
GetEligibilityCheck -
LoanEvaluationCheck -
GetLoanEligibility -
InstantCheck
Each of these calls Predictor.MaybeRunFloatCheck and then underwritingmodel.ApplyOverride
(both in pkg/underwriting_model/predictor.go) against the same cached EvaluationResult.ResultID
for as long as the Rule Runner/Result Runner haven’t produced a fresh evaluation. Any one of the
five endpoints can be the first call that generates and saves a prediction (an MLResult row);
any subsequent call for that same ResultID reuses the saved prediction rather than re-invoking
SageMaker.
Cross-Service Flow
The mobile client (floatme-flutter) calls underwriting-api’s eligibility endpoints directly to
show the user a decision, then calls float-service’s create-float endpoint, which independently
calls back into underwriting-api’s `FloatRequest to re-check eligibility server-side before
actually creating the float — float-service correctly respects whatever Approved/Result
underwriting-api returns (including an authoritative override in either direction) and refuses to
create the float if not approved, and note float-service uses the client-requested amount for
the float record, not underwriting-api’s Amount field (irrelevant during shadow mode, but a
change here would matter to the amount side of an override).
No evaluation/result ID is threaded from the client’s check into float-service’s re-check — each
independently resolves "whatever evaluation is currently latest" for the user
(getEvaluationResult, pkg/api/float_check.go). In the common case (no recalculation lands in
between) both calls hit the same EvaluationResult/MLResult, so float-service’s check is the
"reuse" path described below. If a recalculation does land in that window (a Plaid transaction
webhook, another event), float-service’s check gets a genuinely fresh evaluation and
authoritative is re-checked live for it — independently of what the client’s earlier check saw.
This means the client-displayed decision and float-service’s authoritative decision can
legitimately disagree across a live rollout-percentage or targeting change in that window; this
is an inherent characteristic of there being no idempotency key between the two calls, not a bug.
Two-Stage Rollout: enabled vs authoritative
Two independent GrowthBook flags gate the model, both evaluated per-user
(GetFeatureEnabledForUser), so rollout can be staged and targeted by percentage or user segment:
| Flag | Effect |
|---|---|
|
Master kill switch. When off, |
|
Shadow-mode gate. When off, the model still runs, saves its prediction, and reports metrics, but |
Important: the authoritative flag’s value is snapshotted into MLResult.Authoritative at
the moment the prediction is first generated and saved. Every subsequent call that reuses the
saved prediction (via GetByEvaluationID) trusts that persisted snapshot rather than
re-checking the live flag — this is deliberate: since all five endpoints share the same
ResultID, re-checking live would let a later call (e.g. the actual float request) disagree with
a decision already shown to the user on an earlier call (e.g. an eligibility check) if the flag’s
effective value for that user changed in between, and it keeps the reuse decision consistent with
floatcreated.settleMLResult (below), which also keys off the persisted field.
Decision Logic
ApplyOverride (pkg/underwriting_model/predictor.go) compares the model’s risk score against the
rank/term threshold resolved when the prediction was generated (see Rank / Term Threshold Table):
-
Model approves (
risk_score >= threshold) and rules denied → override to approve (uses the legacy-1"approved" signal; the actual dollar amount comes from the float profile). -
Model denies (
risk_score < threshold) and rules approved → override to deny. -
Otherwise (model and rules agree, or
v2Resultisnil— shadow mode, kill switch off, or no saved prediction) → no-op, rule engine’s decision stands.
Rank / Term Threshold Table
The risk score is compared against a threshold looked up by the user’s rank (1–8, derived from
prior_borrow_count) and term (contract_lag in days, bucketed into rungs) — see
pkg/underwriting_model/threshold_table.go. The table is GrowthBook-configurable
(underwriting.payback_predictor_v2_threshold_table); if it can’t be loaded, parsed, or is missing
a row for the user’s rank, a hardcoded defaultThresholdTable is used instead. Changing the number
of term rungs or their day cutoffs is a GrowthBook edit, not a code change.
Features
-
Cold features — extracted from rule outcomes already stored by the model-feature rules registered in
pkg/rulerunner/model_feature_rules.go(e.g.RuleFloatRank,RuleTransactionCategoryFeatures,RuleSubscriptionRank). Every cold feature must be present; a missing or unparseable one aborts the evaluation (extractPaybackPredictorV2Features), so a partially-featured request is never sent to SageMaker. -
Hot features — computed live at call time from external services (login counts from the SageMaker Feature Store, account age from the user service, contract lag from the insight service, days since last float paid back from
RuleRecentFloat’s outcome). Unlike cold features, a hot-feature fetch failure does not abort the evaluation — the field is left `nil(→NaNfor XGBoost) and the model still runs, which means an authoritative override can happen on a partially-featured request. This is intentional (XGBoost handlesNaNnatively) but isn’t currently visible in the metrics below.-
last_float_paid_backis nil only whenprior_borrow_countis nil, i.e. the user has never taken out a float at all. A user who has borrowed before but never completed a repayment instead gets a huge day count computed off Go’s zerotime.Time— that’s a distinct signal from never having borrowed, and reproduces a training-time quirk whereRuleRecentFloatused to write that zero-time value intorecent_float_datefor "no completed float" (see `extractLastFloatPaidBackFeatures’s doc comment).
-
Good Standing Gate
The model is never run for a user who isn’t in good standing — regardless of the prediction, they
wouldn’t be approved, so there’s no reason to spend a SageMaker call or produce a score.
checkGoodStanding (predictor.go) runs first in runAndSave, right after rule outcomes are
fetched and before cold-feature extraction even starts:
-
Looks for the dedicated model-feature outcome (
RuleGoodStandingV2, registered inpkg/rulerunner/model_feature_rules.goalongside the other model-feature rules — sameRuleGoodStandingGo function,RuleTypeTxns, matching the RuleType early-advance data (ruleData.EarlyAdvance) is populated for, so this registration computes early-advance features correctly too, same as a real rulebook’s own registration). -
If that hasn’t run yet for this user (a brand-new registration — every current rulebook already runs
RuleGoodStanding, just under the unsuffixed"good_standing"name), falls back to that existing rulebook-configured outcome instead of blocking the model outright. This is what lets the gate roll out silently: most users already have a"good_standing"outcome from their normal rulebook run, so the fallback succeeds immediately for them, while the dedicatedV2outcome fills in gradually as users get a fresh rule run. -
Checks
outcome.Loandirectly (-1= good standing,0= not,Error= can’t be determined — treated the same as not in good standing, fail closed).
Any of these — not found, errored, or not in good standing — logs the same
"payback predictor v2: not running model" warning the other cold-feature/rank gates use, with a
reason distinguishing which case it was.
Failure Handling
Every failure path in runAndSave fails closed — returns nil so ApplyOverride no-ops and the
rule engine’s decision is what’s used: rule-outcome fetch errors, not being in good standing,
missing/unparseable cold features, an errored RuleFloatRank outcome, unresolvable contract_lag,
SageMaker inference errors, and MLResult save failures. GrowthBook itself being unreachable
defaults every flag lookup to false (fail-safe).
Persistence & Audit (MLResult)
Each generated prediction is saved as an MLResult row (pkg/dynamo/ml_result.go) keyed to the
EvaluationResult, with a 168-hour TTL. When a float is actually created,
floatcreated.Handler.settleMLResult (pkg/floatcreated/handler.go) looks up the MLResult for
that evaluation and — only if its persisted Authoritative field is true — records the
float_id and strips the TTL so the record is retained permanently for audit. This runs as a
separate step after the handler’s normal historical-evaluation save
(CreateHistoricalFloatEval); it doesn’t gate or replace that save. Predictions that were never
authoritative simply expire after 7 days.
CreateHistoricalFloatEval’s historical-evaluation save is unchanged: it still resolves "whatever
evaluation is latest right now" via `GetLatest. settleMLResult’s evaluation ID is resolved
separately, though, preferring the float event’s `EvaluationId (set by float-service to the exact
ResultID that decided the float) over that latest evaluation’s ResultID — falling back to the
latter only when EvaluationId is absent (older events). This matters because settlement runs
asynchronously, after the float already exists; using "whatever is latest right now" instead of
the evaluation that actually decided the float would settle (or fail to settle) the wrong
MLResult whenever a recalculation (e.g. a Plaid webhook, or another concurrently-processing
float for the same user) lands in that window. The historical-eval record itself doesn’t carry
this same risk in scope here — only MLResult settlement was changed.
Observability
-
underwriting.payback_predictor_v2.inference— emitted for every saved prediction (emitPaybackPredictorV2Metric), tagged withmodel_version,risk_score,authoritative,rank,term,threshold,exceeds_threshold. -
underwriting.payback_predictor_v2.override— emitted on every authoritativeApplyOverridecall (i.e. wheneverv2Resultis non-nil), tagged withmodel_passandrulebook_pass(bothtrue/false) reflecting the model’s and rule engine’s decisions independently, rather than pre-computing an "overridden" tag — group by both in the dashboard to see agreement vs. override, independently of the per-inference metric above.
There is currently no Datadog monitor configured against either metric (see deploy/datadog.tf)
— alerting on override rate is left to whoever sets one up during/after rollout.
See Also
-
Rule Engine - The rulebook-driven decision this can override
-
Rules Reference: ML Payback Prediction - The unrelated v1 in-rulebook ML rules
-
Lambda Functions -
PAYBACK_PREDICTOR_ENDPOINT_NAMEand other ML client env vars -
DynamoDB Schema -
MLResultentity -
$200 Float-Limit Test - Consumer of this model’s risk score, independent of its own authoritative rollout