Causal models provide:
Causal models DON'T provide:
- Optimal decisions under portfolio constraints
- Segment-level pricing (operational requirement)
- Multi-term coordination
- Volume guarantees
Solution: Two-stage framework
Stage 1: Causal Model → Predictions
Stage 2: Optimization → Decisions under constraints
-
$\mathcal{S}$ : Segments (indexed by$s$ ) -
$\mathcal{T}$ : Terms (indexed by$t$ , e.g.,${1,2,3,5}$ years) -
$\mathcal{P}$ : Price options (200 discrete margins) -
$b_i$ : Balance of mortgage$i$ -
$c_i$ : Cost of funds for mortgage$i$ -
$P_{i,t,p}$ : Renewal probability (from causal model) -
$V_{\min}$ : Minimum required volume
Expected profit for mortgage
Total portfolio profit:
Volume constraint:
Price bounds:
Segment average expected profit: $$\bar{\pi}{s,t,p} = \frac{1}{|s|} \sum{i \in s} (p - c_i) \times b_i \times t \times P_{i,t,p}$$
Reformulated objective: $$\max_{x_{s,t}} \sum_{s,t} |s| \times \bar{\pi}{s,t,x{s,t}}$$
Portfolio: 3,500 mortgages, 3 segments, 3-year term, 5 price options
| Segment | # | Avg Balance | Avg CoF |
|---|---|---|---|
| S1 (Shoppers) | 1,000 | $300K | 1.5% |
| S2 (Aware) | 2,000 | $250K | 1.6% |
| S3 (Loyal) | 500 | $400K | 1.4% |
Renewal Probabilities:
| Price | S1 | S2 | S3 |
|---|---|---|---|
| 3.29% | 0.92 | 0.91 | 0.94 |
| 3.49% | 0.88 | 0.89 | 0.93 |
| 3.69% | 0.75 | 0.85 | 0.92 |
| 3.89% | 0.60 | 0.78 | 0.91 |
| 3.99% | 0.50 | 0.73 | 0.90 |
S1 @ 3.49%:
[(0.0349 - 0.015) \times 300K \times 3 \times 0.88 =
S2 @ 3.89%: [(0.0389 - 0.016) \times 250K \times 3 \times 0.78 =
S3 @ 3.99%: [(0.0399 - 0.014) \times 400K \times 3 \times 0.90 =
Unconstrained optimal: $$56.6M$ profit,
Problem: Violates 85% volume constraint (
Constrained solution: Lower S2 to 3.49% → $$55.0M$ profit,
Trade-off: Sacrifice $$1.6M$ profit to meet volume target
# Pseudocode
def optimize_portfolio(df_predictions, config):
# Step 1: Expand for terms
df = expand_terms(df_predictions, terms=[1,2,3,5])
# Step 2: Calculate profit
df['profit'] = (df['margin'] - df['cof']) * df['balance'] * df['term']
df['expected_profit'] = df['profit'] * df['renewal_prob']
# Step 3: Segment by elasticity
df['segment'] = assign_segments(df, method='elasticity_quantiles')
# Step 4: Smooth curves (isotonic + spline)
df['renewal_prob_smooth'] = smooth_curves(df, group_by=['segment','term'])
# Step 5: Aggregate to segment level
segment_data = df.groupby(['segment','term','margin']).agg({
'expected_profit': 'mean',
'renewal_prob_smooth': 'mean'
})
# Step 6: Optimize per term
results = {}
for term in [1,2,3,5]:
results[term] = optimize_lp(segment_data[term], config.min_volume)
return format_recommendations(results)from scipy.interpolate import UnivariateSpline
from sklearn.isotonic import IsotonicRegression
def smooth_curve(margins, renewal_probs, s=0.03):
# Stage 1: Enforce monotonicity
iso = IsotonicRegression(increasing=False, out_of_bounds='clip')
probs_mono = iso.fit_transform(margins, renewal_probs)
# Stage 2: Spline smoothing
spline = UnivariateSpline(margins, probs_mono, s=s, k=3)
return np.clip(spline(margins), 0, 1)Why: Causal predictions are noisy at extremes; smoothing ensures monotonicity and stability
Price elasticity:
def create_segments(df, n_segments=20):
# Calculate elasticity for each mortgage
elasticities = calculate_arc_elasticity(df)
# Quantile-based segmentation
df['segment'] = pd.qcut(elasticities, q=n_segments, labels=False)
return dfBenefit: Reduces from O(millions) to O(thousands) of variables
1. Discrete (Differential Evolution):
- Select from exact 200 price points
- Global search over discrete grid
- Use for non-smooth objectives
2. Continuous (SLSQP):
- Interpolate between prices (cubic splines)
- Smooth optimization landscape
- Faster convergence
3. Linear Programming (Recommended):
- Formulate as discrete choice (MIP)
- Provably optimal solution
- Fastest for large-scale problems
from ortools.linear_solver import pywraplp
def optimize_lp(segment_data, min_volume):
solver = pywraplp.Solver.CreateSolver('GLOP')
# Binary variables: y[s,p] = 1 if segment s chooses price p
y = {(s,p): solver.BoolVar(f'y_{s}_{p}')
for s in segments for p in prices}
# Each segment chooses exactly one price
for s in segments:
solver.Add(sum(y[s,p] for p in prices) == 1)
# Objective: maximize profit
solver.Maximize(sum(y[s,p] * profit[s,p] for s,p in y))
# Volume constraint
solver.Add(sum(y[s,p] * volume[s,p] for s,p in y) >= min_volume)
solver.Solve()
return extract_solution(y)Computational Complexity:
- Without segmentation: O(millions of mortgages) - infeasible
- With segmentation: O(10 segments × 4 terms × 200 prices) ≈ 8K variables
- Solve time: <1 minute
Typical Results:
- Profit lift: 8-15% vs one-size-fits-all
- Volume control: Guaranteed 85-95% retention
- Segmentation efficiency: 90% of personalization value at 1% computational cost
for month in historical_months:
train = data[data.month < month]
test = data[data.month == month]
recommendations = optimizer.run(train)
actuals = test.merge(recommendations)
metrics[month] = {
'predicted_profit': recommendations.profit.sum(),
'actual_profit': actuals.profit.sum(),
'error': abs(predicted - actual) / actual
}| Aspect | Causal Model Alone | With Optimization |
|---|---|---|
| Predictions | ✓ | ✓ |
| Portfolio constraints | ✗ | ✓ |
| Segment pricing | ✗ | ✓ |
| Multi-term coordination | ✗ | ✓ |
| Volume guarantees | ✗ | ✓ |
| Actionable decisions | ✗ | ✓ |
Bottom Line: Causal models predict elasticity to the price/rate; optimization prescribes strategy under real-world constraints.