Two-Sided Matching & Ranking for Credit Cards#
Below is a high-level framework to build a two-sided matching system for credit cards, balancing your revenue (fees) and customers’ utility (satisfaction). It also covers ranking multiple recommendations rather than suggesting just one.
1. Understand the Two-Sided Nature of the Market#
Supply Side (Banks & Cards)
Each bank has multiple credit card products.
Attributes include:
APR or interest rate
Annual fee
Rewards structure (cashback, miles, points, etc.)
Sign-up bonus
Issuance constraints (e.g., min. credit score)
Platform revenue: Different cards/banks pay different referral commissions.
Demand Side (Customers)
Each customer has attributes like:
Credit score & income
Spending habits & preferences
They want a card that best suits their needs (max utility).
“Hard constraints”: Must meet credit score, location, income requirements.
“Soft preferences”: Type of rewards, brand preference, annual fee tolerance, etc.
Key: You need an approach that accounts for both sides: maximizing platform revenue (fees) and user satisfaction (utility).
2. Overall Pipeline / Flow#
Filtering / Eligibility Check (Hard Constraints)
Eliminate cards the customer cannot qualify for (score, income, location).
Utility Modeling (Soft Criteria)
For each (customer, card) pair that survives the filter, compute a “user preference score.”
This can be done via heuristic (rule-based) or ML (learning-to-rank, collaborative filtering).
Revenue Modeling
Estimate expected revenue per (customer, card):
$\( \text{expected\_revenue} = \text{commission}(card) \times P(\text{approval}) \times P(\text{acceptance}) \)$
Combine Utility & Revenue
Use a weighted combination or multi-objective approach:
$\( \text{Final Score} = \alpha \cdot \text{Utility} + (1 - \alpha) \cdot \text{Revenue} \)$
Ranking & Output
Rank feasible cards by final score and present top N suggestions.
3. Hard vs. Soft Criteria Handling#
Hard Criteria (Filters)
Credit score thresholds, minimum income, region restrictions.
Any card failing these should be removed from consideration.
Soft Criteria (Preferences)
Rewards type (cashback, points, miles).
Annual fee tolerance.
Brand preference.
Use a scoring formula or ML model to assess how well each card meets user’s preferences.
4. Data & Model Considerations#
A. Data You Might Need#
Historical Interactions
Which cards were shown to which users.
Which card they chose (if any), approval status, ongoing usage, etc.
Card Metadata
Commissions, APR, annual fees, reward structures.
User Features
Demographics, credit profile, stated preferences.
B. Models to Consider#
Approval Probability Model
Probability of being approved given user’s credit score, income, etc.
Acceptance / Conversion Model
Probability the user will actually apply if shown the card.
User Satisfaction / Retention Model
If data exists, predict how satisfied the user will be over time.
5. Multi-Objective Optimization#
Weighted Sum Approach
$\( \text{Final Score} = \alpha \cdot U(\text{user}, \text{card}) \;+\; (1-\alpha)\times R(\text{user}, \text{card}) \)$Adjust \(\alpha\) via experiments (A/B tests).
Pareto Frontier
More advanced. Finds non-dominated solutions in terms of user utility & revenue.
Constraint-Based
E.g., “We want at least X user utility,” then maximize revenue (or vice versa).
6. Ranking Mechanism and Presentation#
Top-K Ranking
Sort the feasible cards by the final score and present the top N.
Optionally label them (“Best for Travel,” “No Annual Fee,” etc.) to help users.
Diversity / Exploration
Consider a diversity factor if you don’t want all top recommendations from the same bank.
Explainability
Show users why you’re recommending a certain card (higher approval chance, great rewards match, etc.).
7. Practical Steps to Start Implementation#
Data Collection & Cleaning
Gather accurate card data (APR, fees, commissions) and user data (score, preferences).
Rule-Based Filter (Hard Constraints)
Immediately remove cards that don’t meet basic eligibility.
Utility & Revenue Estimation
expected_revenue = commission * prob_approve * prob_acceptuser_utility_scorecan be a simple rule-based or an ML model.
Combine Scores
$\( \text{final\_score} = \alpha \times \text{user\_utility\_score} \;+\; (1-\alpha)\times \text{expected\_revenue} \)$Ranking & Display
Sort by
final_scoreand present the top options.
A/B Testing & Iteration
Experiment with different \(\alpha\), different scoring methods, and measure outcomes.
8. Additional Nuances#
Regulatory / Compliance
Ensure no discrimination or violation of lending regulations.
Bank Preferences
If banks want specific types of customers, integrate their preference as another factor in your model or constraints.
Cold Start Problems
For new users/cards, rely on aggregate or attribute-based estimations until real interaction data is available.
Online Learning / Bandit Approaches
Advanced techniques to adapt in real time based on user responses.
Conclusion#
Filter out ineligible cards.
Score each candidate on both user utility and expected revenue.
Combine those scores via a multi-objective method.
Rank feasible cards and present top picks (not just one).
Iterate through A/B testing to optimize both user satisfaction and your revenue.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import random
# -------------------------------------------------------
# 1. GENERATE RANDOM DATA
# - 30 card products
# - 1000 customers
# -------------------------------------------------------
# For reproducibility, set a random seed
np.random.seed(42)
random.seed(42)
# Let's define some sets of possible attributes
BANKS = ["Bank A", "Bank B", "Bank C", "Bank D", "Bank E"]
REWARD_TYPES = ["Cashback", "Miles", "Points"]
def generate_random_cards(n_cards=30):
"""
Generate 'n_cards' credit card records with random attributes.
"""
card_records = []
for i in range(n_cards):
bank = np.random.choice(BANKS)
card_name = f"{bank} Card #{i+1}"
# Randomly assign min credit score (range: 600-750)
credit_score_min = np.random.randint(600, 751)
# Random commission (platform revenue) in dollars
commission = np.random.randint(50, 201) # e.g. between $50 - $200
# APR range ~ [10%, 25%]
apr = round(np.random.uniform(10, 25), 2)
# Annual fee: 0, 49, 99, 199, etc.
annual_fee_options = [0, 29, 49, 95, 99, 199]
annual_fee = np.random.choice(annual_fee_options)
# Reward type
reward_type = np.random.choice(REWARD_TYPES)
card_records.append({
"Bank": bank,
"Card": card_name,
"CreditScoreMin": credit_score_min,
"Commission": commission,
"APR": apr,
"AnnualFee": annual_fee,
"RewardType": reward_type
})
df_cards = pd.DataFrame(card_records)
return df_cards
def generate_random_customers(n_customers=100):
"""
Generate 'n_customers' user/customer records with random attributes.
Each user will have:
- CreditScore
- Income
- RewardPreference
- MaxAnnualFee
- Possibly brand preference (soft preference for a particular bank)
- alpha: trade-off factor (0 to 1) between utility vs. revenue
"""
customer_records = []
# We'll define some brand preference probability distribution
brand_prefs = BANKS + ["NoPreference"]
for i in range(n_customers):
# Credit score in [600..800]
credit_score = np.random.randint(600, 801)
# Income in [30k..150k]
income = np.random.randint(30000, 150001)
# Reward preference
reward_pref = np.random.choice(REWARD_TYPES)
# Max annual fee tolerance (0..200 in steps)
max_fee = np.random.choice([0, 29, 49, 95, 99, 199])
# Brand preference (soft). Could be "NoPreference" or one of the 5 banks
brand_pref = np.random.choice(brand_prefs)
# alpha in [0..1], the weighting for utility vs. revenue
alpha = round(np.random.uniform(0, 1), 2)
customer_records.append({
"CustomerID": i+1,
"CreditScore": credit_score,
"Income": income,
"RewardPreference": reward_pref,
"MaxAnnualFee": max_fee,
"BrandPreference": brand_pref,
"alpha": alpha
})
df_customers = pd.DataFrame(customer_records)
return df_customers
# Generate the data
df_cards = generate_random_cards(n_cards=30)
df_customers = generate_random_customers(n_customers=1000)
# -------------------------------------------------------
# 2. FILTERING FUNCTION (HARD CONSTRAINTS)
# -------------------------------------------------------
def filter_cards(customer, df_cards):
"""
Hard constraints:
- customer's credit score >= card's min score
- card's annual fee <= customer's maxAnnualFee
"""
eligible = df_cards[
(df_cards["CreditScoreMin"] <= customer["CreditScore"]) &
(df_cards["AnnualFee"] <= customer["MaxAnnualFee"])
].copy()
return eligible
# -------------------------------------------------------
# 3. UTILITY & REVENUE CALCULATIONS
# -------------------------------------------------------
def probability_of_approval(customer, card):
"""
Toy model:
Probability of approval depends on how much the customer's score
exceeds the card's min required score.
We'll clamp the probability to [0, 0.95].
"""
gap = customer["CreditScore"] - card["CreditScoreMin"]
if gap < 0:
return 0.0
# Make a base ~ 0.5, plus gap * 0.005
prob = 0.5 + (gap * 0.005)
prob = min(prob, 0.95)
return prob
def probability_of_acceptance(customer, card):
"""
Toy model for acceptance:
- If reward matches user preference => 0.7 base, else 0.4
- If bank matches user brand preference => add +0.1
Cap at 0.95
"""
prob = 0.4
if card["RewardType"].lower() == customer["RewardPreference"].lower():
prob = 0.7
# brand preference effect
if customer["BrandPreference"] == card["Bank"]:
prob += 0.1
# clamp
prob = min(prob, 0.95)
return prob
def user_utility_score(customer, card):
"""
Simple heuristic-based user utility:
- +5 if reward matches user's preference
- + up to 5 points if APR is low
- + up to 5 points if annual fee is low
- +3 if brand matches user brand preference
"""
score = 0
# reward match
if card["RewardType"].lower() == customer["RewardPreference"].lower():
score += 5
# APR: the lower the better
apr = card["APR"]
if apr <= 12:
score += 5
elif apr <= 16:
score += 3
else:
score += 1
# annual fee
if card["AnnualFee"] == 0:
score += 5
elif card["AnnualFee"] <= 50:
score += 3
else:
score += 1
# brand preference
if customer["BrandPreference"] == card["Bank"]:
score += 3
return score
# -------------------------------------------------------
# 4. RECOMMEND CARDS FOR A SINGLE CUSTOMER
# -------------------------------------------------------
def recommend_cards_for_customer(customer, df_cards, top_k=5):
"""
Filter -> compute scores -> rank -> return top_k.
"""
# Filter
eligible = filter_cards(customer, df_cards)
if eligible.empty:
return pd.DataFrame()
alpha = customer["alpha"]
records = []
for idx, card in eligible.iterrows():
prob_approve = probability_of_approval(customer, card)
prob_accept = probability_of_acceptance(customer, card)
expected_revenue = card["Commission"] * prob_approve * prob_accept
utility = user_utility_score(customer, card)
final_score = alpha * utility + (1 - alpha) * expected_revenue
records.append({
"Bank": card["Bank"],
"Card": card["Card"],
"CreditScoreMin": card["CreditScoreMin"],
"Commission": card["Commission"],
"APR": card["APR"],
"AnnualFee": card["AnnualFee"],
"RewardType": card["RewardType"],
"ProbApproval": round(prob_approve, 3),
"ProbAcceptance": round(prob_accept, 3),
"UserUtility": utility,
"ExpectedRevenue": round(expected_revenue, 2),
"FinalScore": round(final_score, 2)
})
df_recs = pd.DataFrame(records)
df_recs.sort_values("FinalScore", ascending=False, inplace=True)
df_recs.reset_index(drop=True, inplace=True)
return df_recs.head(top_k)
# -------------------------------------------------------
# 5. EVALUATE THE SYSTEM FOR MULTIPLE ALPHAS
# - We'll compute:
# (a) total revenue across all customers
# (b) average top-1 user utility across all customers
# -------------------------------------------------------
def evaluate_system_for_alphas(df_customers, df_cards, alpha_values=[0.1, 0.3, 0.5, 0.7, 0.9]):
"""
For each alpha in alpha_values:
- Temporarily set each customer's alpha to that alpha
- For each customer, get top recommended card (top-1)
- Sum expected revenue, average user utility
Return a DataFrame summarizing the results.
"""
results = []
for alpha in alpha_values:
total_revenue = 0.0
total_utility = 0.0
count_valid = 0
for idx, cust in df_customers.iterrows():
# Make a copy of the customer's data but override alpha
customer = dict(cust)
customer["alpha"] = alpha
# Get top recommended card
df_top = recommend_cards_for_customer(customer, df_cards, top_k=1)
if not df_top.empty:
# We only have one row here
row = df_top.iloc[0]
total_revenue += row["ExpectedRevenue"]
total_utility += row["UserUtility"]
count_valid += 1
if count_valid == 0:
avg_utility = 0
else:
avg_utility = total_utility / count_valid
results.append({
"Alpha": alpha,
"TotalExpectedRevenue": round(total_revenue, 2),
"AvgUserUtility_top1": round(avg_utility, 2)
})
df_eval = pd.DataFrame(results)
return df_eval
# -------------------------------------------------------
# 6. RUN THE EVALUATION & AGGREGATE RESULTS
# -------------------------------------------------------
# Choose a set of alpha values
alpha_vals = [0.0, 0.1, 0.3, 0.5, 0.7, 0.9, 1.0]
df_evaluation = evaluate_system_for_alphas(df_customers, df_cards, alpha_values=alpha_vals)
print("=== System Evaluation for Different alpha Values ===")
print(df_evaluation)
# We'll do a simple plot: alpha vs. total revenue & avg user utility
fig, ax1 = plt.subplots(figsize=(8,5))
ax2 = ax1.twinx()
ax1.plot(df_evaluation["Alpha"], df_evaluation["TotalExpectedRevenue"], 'g-o', label="Total Revenue")
ax2.plot(df_evaluation["Alpha"], df_evaluation["AvgUserUtility_top1"], 'b-o', label="Avg Utility")
ax1.set_xlabel("Alpha (weight on User Utility)")
ax1.set_ylabel("Total Expected Revenue", color='g')
ax2.set_ylabel("Average User Utility (top-1)", color='b')
plt.title("Trade-off: Utility vs. Revenue by Alpha")
ax1.grid(True)
plt.show()
# -------------------------------------------------------
# 7. DEMONSTRATE A 'CUSTOMER JOURNEY'
# - We'll pick one random customer and show the recommended cards list
# -------------------------------------------------------
random_customer_index = np.random.randint(0, len(df_customers))
customer_journey = df_customers.iloc[random_customer_index].to_dict()
print("\n=== Example Customer Journey ===")
print(f"Selected Customer ID: {customer_journey['CustomerID']}")
print("Customer Attributes:")
print(customer_journey)
# Now let's see the top-5 recommended cards for this customer
df_customer_recs = recommend_cards_for_customer(customer_journey, df_cards, top_k=5)
if df_customer_recs.empty:
print("No eligible cards for this customer.")
else:
print("\nRecommended Cards (Top-5):\n", df_customer_recs)
---------------------------------------------------------------------------
ValueError Traceback (most recent call last)
Cell In[1], line 2
1 import numpy as np
----> 2 import pandas as pd
3 import matplotlib.pyplot as plt
4 import random
5
File /opt/hostedtoolcache/Python/3.11.15/x64/lib/python3.11/site-packages/pandas/__init__.py:22
19 del _hard_dependencies, _dependency, _missing_dependencies
21 # numpy compat
---> 22 from pandas.compat import is_numpy_dev as _is_numpy_dev # pyright: ignore # noqa:F401
24 try:
25 from pandas._libs import hashtable as _hashtable, lib as _lib, tslib as _tslib
File /opt/hostedtoolcache/Python/3.11.15/x64/lib/python3.11/site-packages/pandas/compat/__init__.py:18
15 from typing import TYPE_CHECKING
17 from pandas._typing import F
---> 18 from pandas.compat.numpy import (
19 is_numpy_dev,
20 np_version_under1p21,
21 )
22 from pandas.compat.pyarrow import (
23 pa_version_under1p01,
24 pa_version_under2p0,
(...) 31 pa_version_under9p0,
32 )
34 if TYPE_CHECKING:
File /opt/hostedtoolcache/Python/3.11.15/x64/lib/python3.11/site-packages/pandas/compat/numpy/__init__.py:4
1 """ support numpy compatibility across versions """
2 import numpy as np
----> 4 from pandas.util.version import Version
6 # numpy versioning
7 _np_version = np.__version__
File /opt/hostedtoolcache/Python/3.11.15/x64/lib/python3.11/site-packages/pandas/util/__init__.py:2
1 # pyright: reportUnusedImport = false
----> 2 from pandas.util._decorators import ( # noqa:F401
3 Appender,
4 Substitution,
5 cache_readonly,
6 )
8 from pandas.core.util.hashing import ( # noqa:F401
9 hash_array,
10 hash_pandas_object,
11 )
14 def __getattr__(name):
File /opt/hostedtoolcache/Python/3.11.15/x64/lib/python3.11/site-packages/pandas/util/_decorators.py:14
6 from typing import (
7 Any,
8 Callable,
9 Mapping,
10 cast,
11 )
12 import warnings
---> 14 from pandas._libs.properties import cache_readonly
15 from pandas._typing import (
16 F,
17 T,
18 )
19 from pandas.util._exceptions import find_stack_level
File /opt/hostedtoolcache/Python/3.11.15/x64/lib/python3.11/site-packages/pandas/_libs/__init__.py:13
1 __all__ = [
2 "NaT",
3 "NaTType",
(...) 9 "Interval",
10 ]
---> 13 from pandas._libs.interval import Interval
14 from pandas._libs.tslibs import (
15 NaT,
16 NaTType,
(...) 21 iNaT,
22 )
File pandas/_libs/interval.pyx:1, in init pandas._libs.interval()
----> 1 'Could not get source, probably due dynamically evaluated source code.'
ValueError: numpy.dtype size changed, may indicate binary incompatibility. Expected 96 from C header, got 88 from PyObject