Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Endogenous Borrowers and Capital: Heterogeneous Project Returns

Authors
Affiliations
Hunter College + The Graduate Center, CUNY
New York University
Financial Access Initiative (FAI)

Social Finance — Notebook 5

This notebook extends the social finance model by introducing within-neighborhood heterogeneity in project returns XsX_s. In the baseline model (notebook 2) and the selfish equity extension (notebook 4), every borrower in a neighborhood had the same project return X=200X = 200. Here we relax this assumption.

Motivation

With a distribution of project qualities Xs∼Uniform[X‾,Xˉ]X_s \sim \text{Uniform}[\underline{X}, \bar{X}] within each neighborhood, the MFI faces a downward-sloping demand for credit: the best projects are funded first, and the marginal project quality declines as the MFI expands. This creates a natural intensive margin:

  • Number of borrowers NN is endogenous — determined by the marginal project cutoff Xs∗X_s^*

  • Capital KK is endogenous — informed equity flows across neighborhoods seeking highest return β\beta

  • The equilibrium jointly determines β∗\beta^*, Nj∗N_j^*, Kj∗K_j^*, and Xs∗jX_s^{*j} in every neighborhood

Key New Insights

  1. Mission drift formalized: Higher β\beta causes MFIs to both drop poor neighborhoods and cream-skim within neighborhoods

  2. Depth vs. breadth: More capital lowers β∗\beta^*, expanding on two margins

  3. Richer donor targeting: The optimal subsidy allocation now depends on the distribution of project quality

Source
import sys
import os
import numpy as np
import matplotlib
# matplotlib.use('Agg')  # non-interactive backend for batch execution
import matplotlib.pyplot as plt
import matplotlib.cm as cm
from scipy.optimize import root_scalar, brentq
from scipy.integrate import quad

# Path configuration to find local packages
sys.path.append(os.path.abspath('..'))

# Matplotlib styling
plt.rcParams['figure.figsize'] = (8.5, 6.0)
plt.rcParams['axes.grid'] = True
plt.rcParams['grid.linestyle'] = ':'
plt.rcParams['grid.alpha'] = 0.6
plt.rcParams['font.size'] = 11

# Import the core Bank class
from socialfinance.socialfinance import Bank, BankHetX

os.makedirs('figs', exist_ok=True)
print('Imports complete.')
Imports complete.

1. Analytical Foundations

The Extended Model

We retain the baseline model from socialfinance.py with one key extension: within each neighborhood jj (characterized by pledgeable assets AjA_j), there is a finite population Nˉ\bar{N} of potential borrowers. Each borrower has a project with idiosyncratic success return XsX_s drawn i.i.d. from a common distribution:

Xs∼Uniform[X‾,  Xˉ]X_s \sim \text{Uniform}[\underline{X},\; \bar{X}]

The same distribution applies across all neighborhoods (neighborhoods differ only in AjA_j).

Modified Contract Equations

For a borrower in neighborhood AjA_j with project return XsX_s, the minimum collateral requirement under leveraged intermediation with monitoring mm and equity return β\beta is:

A‾(m,Xs)=p(B0−αm)Δ−[pXs−γI]+m+β−γβqmΔ+βf\underline{A}(m, X_s) = \frac{p(B_0 - \alpha m)}{\Delta} - [p X_s - \gamma I] + m + \frac{\beta - \gamma}{\beta} \frac{q m}{\Delta} + \beta f

Here, the operational fixed cost per borrower f=F/Njf = F/N_j is priced at the cost of debt, +βf+ \beta f. This is the result of a borrower-specific Weighted Average Cost of Capital (WACC):

WACCi=γ+(β−γ)wei,where wei=Im+fI+fWACC_i = \gamma + (\beta - \gamma) w_e^i, \quad \text{where } w_e^i = \frac{I^m + f}{I + f}

Economic Nuances in Fixed Cost Financing: In our new formulation, the loan fixed operational cost ff must always be financed out of MFI equity capital (at opportunity cost β\beta). Thus, it carries the equity premium +βf+ \beta f. This bounds maximum leverage at I/f=5.0I/f = 5.0 and resolves outreach discontinuities.

Optimal Monitoring

Inverting A‾(m,Xs)=Aj\underline{A}(m, X_s) = A_j, the required monitoring for a borrower (Aj,Xs)(A_j, X_s) is:

m∗(Aj,Xs)=[A‾(0,Xs)−Aj]⋅β(p−q)(α−1)βp+γqm^*(A_j, X_s) = \frac{[\underline{A}(0, X_s) - A_j] \cdot \beta(p - q)}{(\alpha - 1)\beta p + \gamma q}

where A‾(0,Xs)=pB0Δ−[pXs−γI]+βf\underline{A}(0, X_s) = \frac{p B_0}{\Delta} - [p X_s - \gamma I] + \beta f is the zero-monitoring collateral requirement, which is decreasing in XsX_s. Higher project return XsX_s means more surplus available, so less monitoring is needed.

Marginal Borrower Cutoff Xs∗X_s^*

The MFI serves borrowers with Xs≥Xs∗(Aj,β)X_s \geq X_s^*(A_j, \beta), where Xs∗X_s^* is determined by either:

(a) Borrower participation: The marginal borrower earns zero surplus:

π(Aj,Xs∗,β)=pXs∗−γI−m∗⋅(1+β−γβqΔ)−βf=0\pi(A_j, X_s^*, \beta) = p X_s^* - \gamma I - m^* \cdot \left(1 + \frac{\beta - \gamma}{\beta} \frac{q}{\Delta}\right) - \beta f = 0

(b) Feasibility: m∗(Aj,Xs∗)≥0m^*(A_j, X_s^*) \geq 0, i.e., the project is rich enough that monitoring is non-negative:

Xs∗≥Xsmin⁡(Aj)≡1p[pB0Δ−Aj+γI+βf]X_s^* \geq X_s^{\min}(A_j) \equiv \frac{1}{p}\left[\frac{pB_0}{\Delta} - A_j + \gamma I + \beta f\right]

The binding constraint is Xs∗=max⁡{Xs(a),Xs(b)}X_s^* = \max\{X_s^{(a)}, X_s^{(b)}\}.

2. Validation: Recovering the Baseline

Before exploring the new model, let’s verify that BankHetX recovers the baseline when XsX_s degenerates to a point mass at X=200X = 200. We also verify the analytical Xs∗X_s^* formula against numerical root-finding.

Source
# --- Validation 1: Point-mass X_s = 200 should match the original Bank ---

A_test = np.array([60.0])
beta_test = 1.0

# Original Bank
bank_orig = Bank(A_test, beta=beta_test)
m_orig = bank_orig.mon(A_test)[0]
Im_orig = bank_orig.Im(m_orig)
surplus_orig = bank_orig.breturn(A_test)[0]

# BankHetX with X_s = 200 (the baseline X)
bank_het = BankHetX(A_test, beta=beta_test)
m_het = bank_het.m_opt_Xs(A_test[0], 200.0)
Im_het = bank_het.Im_Xs(m_het)
surplus_het = bank_het.borrower_surplus_Xs(A_test[0], 200.0)

print("=== Validation: BankHetX at X_s=200 vs Original Bank ===")
print(f"  Monitoring:  Original = {m_orig:.4f},  HetX = {m_het:.4f}")
print(f"  Equity I^m:  Original = {Im_orig:.4f},  HetX = {Im_het:.4f}")
print(f"  Surplus:     Original = {surplus_orig:.4f},  HetX = {surplus_het:.4f}")
print()

# --- Validation 2: X_star formula ---
print("=== Validation: X_star cutoff ===")
for A_j in [30, 60, 90, 120]:
    xs_analytical = bank_het.X_star(A_j)
    # Numerical check: find X_s where surplus = 0
    try:
        xs_numerical = brentq(lambda x: bank_het.borrower_surplus_Xs(A_j, x) 
                              - 1e-10, bank_het.X_lo, bank_het.X_hi)
    except ValueError:
        xs_numerical = float('nan')
    print(f"  A_j={A_j:3d}:  X_star(analytical) = {xs_analytical:.2f},  X_star(numerical) = {xs_numerical:.2f}")
=== Validation: BankHetX at X_s=200 vs Original Bank ===
  Monitoring:  Original = 26.8657,  HetX = 26.8657
  Equity I^m:  Original = 100.0000,  HetX = 146.8657
  Surplus:     Original = 47.1343,  HetX = 47.1343

=== Validation: X_star cutoff ===
  A_j= 30:  X_star(analytical) = 176.00,  X_star(numerical) = 176.00
  A_j= 60:  X_star(analytical) = 166.44,  X_star(numerical) = 166.44
  A_j= 90:  X_star(analytical) = 160.00,  X_star(numerical) = nan
  A_j=120:  X_star(analytical) = 160.00,  X_star(numerical) = nan

3. The Credit Demand Curve Within a Neighborhood

For a single neighborhood AjA_j, we can now visualize the intensive margin: how the MFI’s lending expands as it dips deeper into the XsX_s distribution.

For each served borrower, we plot:

  • Required monitoring m∗(Aj,Xs)m^*(A_j, X_s) — declining in XsX_s

  • Borrower surplus π(Aj,Xs)\pi(A_j, X_s) — increasing in XsX_s

  • MFI equity stake Im(Aj,Xs)I^m(A_j, X_s) — declining in XsX_s

The marginal borrower at the cutoff Xs∗X_s^* earns zero surplus and absorbs the most monitoring.

Source
# Credit demand curves for selected neighborhoods

beta_vals_to_show = [1.0, 1.3]
A_neighborhoods = [40, 70, 100]
X_grid = np.linspace(120, 280, 300)

fig, axes = plt.subplots(3, len(A_neighborhoods), figsize=(16, 12), sharex=True)

for col, A_j in enumerate(A_neighborhoods):
    for beta_v, ls, lbl_sfx in zip(beta_vals_to_show, ['-', '--'], 
                                     [r'$\beta=1.0$', r'$\beta=1.3$']):
        bank = BankHetX(np.array([A_j]), beta=beta_v)
        xs = bank.X_star(A_j)
        
        m_vals = np.array([bank.m_opt_Xs(A_j, x) for x in X_grid])
        surplus_vals = np.array([bank.borrower_surplus_Xs(A_j, x) for x in X_grid])
        Im_vals = np.array([bank.Im_Xs(bank.m_opt_Xs(A_j, x)) for x in X_grid])
        
        # Zero out below X_star
        mask = X_grid >= xs
        
        axes[0, col].plot(X_grid[mask], m_vals[mask], ls, linewidth=2, label=lbl_sfx)
        axes[0, col].axvline(x=xs, color='red' if beta_v > 1.1 else 'blue', 
                             linestyle=':', alpha=0.5)
        
        axes[1, col].plot(X_grid[mask], surplus_vals[mask], ls, linewidth=2, label=lbl_sfx)
        axes[1, col].axhline(y=0, color='grey', linestyle='-', alpha=0.3)
        axes[1, col].axvline(x=xs, color='red' if beta_v > 1.1 else 'blue', 
                             linestyle=':', alpha=0.5)
        
        axes[2, col].plot(X_grid[mask], Im_vals[mask], ls, linewidth=2, label=lbl_sfx)
        axes[2, col].axvline(x=xs, color='red' if beta_v > 1.1 else 'blue', 
                             linestyle=':', alpha=0.5)
    
    axes[0, col].set_title(f'Neighborhood $A_j = {A_j}$', fontsize=12, weight='bold')

axes[0, 0].set_ylabel('Monitoring $m^*$')
axes[1, 0].set_ylabel('Borrower surplus $\\pi$')
axes[2, 0].set_ylabel('MFI equity stake $I^m$')

for ax in axes[2, :]:
    ax.set_xlabel('Project return $X_s$')

for ax in axes.flat:
    ax.legend(fontsize=8)

fig.suptitle('Within-Neighborhood Credit Demand: Intensive Margin', 
             fontsize=14, weight='bold', y=1.01)
plt.tight_layout()
plt.savefig('figs/fig-within-nbhd-demand.png', dpi=300, bbox_inches='tight')
plt.show()
<Figure size 1600x1200 with 9 Axes>

4. The Outreach Landscape with Heterogeneous Projects

Now we can compute the outreach landscape across neighborhoods. For each neighborhood AjA_j, we compute:

  1. Fraction served [1−G(Xs∗(Aj,β))][1 - G(X_s^*(A_j, \beta))] — what share of the local population gets credit

  2. Total equity demanded Kj(β)K_j(\beta) — how much monitoring capital is absorbed

  3. Total borrower surplus — welfare generated

We compare the social benchmark (β=γ=1.0\beta = \gamma = 1.0) with a range of commercial hurdle rates.

Source
# Outreach landscape across neighborhoods for different beta

A_nbhds = np.linspace(5, 140, 100)
betas_compare = [1.0, 1.1, 1.2, 1.3, 1.5]
colors_beta = cm.coolwarm(np.linspace(0.0, 1.0, len(betas_compare)))

fig, axes = plt.subplots(2, 2, figsize=(15, 11))

for beta_v, color in zip(betas_compare, colors_beta):
    frac_served_vals = []
    N_served_vals = []
    K_demand_vals = []
    total_surplus_vals = []
    X_star_vals = []
    
    for A_j in A_nbhds:
        bank = BankHetX(np.array([A_j]), beta=beta_v)
        frac_served_vals.append(bank.frac_served(A_j))
        N_served_vals.append(bank.N_served(A_j))
        K_demand_vals.append(bank.K_demand_nbhd(A_j))
        total_surplus_vals.append(bank.total_surplus_nbhd(A_j))
        X_star_vals.append(bank.X_star(A_j))
    
    lbl = rf'$\beta = {beta_v:.1f}$'
    
    axes[0, 0].plot(A_nbhds, frac_served_vals, color=color, linewidth=2, label=lbl)
    axes[0, 1].plot(A_nbhds, X_star_vals, color=color, linewidth=2, label=lbl)
    axes[1, 0].plot(A_nbhds, K_demand_vals, color=color, linewidth=2, label=lbl)
    axes[1, 1].plot(A_nbhds, total_surplus_vals, color=color, linewidth=2, label=lbl)

axes[0, 0].set_title('Fraction of Population Served', weight='bold')
axes[0, 0].set_ylabel('Fraction served')
axes[0, 0].set_ylim(0, 1.05)

axes[0, 1].set_title(r'Marginal Project Cutoff $X_s^*$', weight='bold')
axes[0, 1].set_ylabel(r'$X_s^*$')
axes[0, 1].axhline(y=120, color='grey', linestyle=':', alpha=0.5, label=r'$X_{lo}$')
axes[0, 1].axhline(y=280, color='grey', linestyle=':', alpha=0.5, label=r'$X_{hi}$')

axes[1, 0].set_title('MFI Equity Capital Demanded per Neighborhood', weight='bold')
axes[1, 0].set_ylabel(r'$K_j$ demanded')

axes[1, 1].set_title('Total Borrower Surplus per Neighborhood', weight='bold')
axes[1, 1].set_ylabel('Total surplus')

for ax in axes.flat:
    ax.set_xlabel(r'Neighborhood collateral $A_j$')
    ax.legend(fontsize=8)
    ax.set_xlim(0, 140)

fig.suptitle('Outreach Landscape with Heterogeneous Project Returns', 
             fontsize=14, weight='bold', y=1.01)
plt.tight_layout()
plt.savefig('figs/fig-het-outreach-landscape.png', dpi=300, bbox_inches='tight')
plt.show()
<Figure size 1500x1100 with 4 Axes>

5. General Equilibrium: Clearing the Capital Market

Now we close the model. Total intermediary capital KtotalK_{total} is fixed in the economy. Informed equity flows to the neighborhoods offering the highest return. In equilibrium, the rate of return β∗\beta^* equalizes across all served neighborhoods.

The aggregate capital demand is:

Kdemand(β)=∑jKj(Aj,β)K_{demand}(\beta) = \sum_j K_j(A_j, \beta)

where KjK_j is the equity absorbed by neighborhood jj. This is a smooth, decreasing function of β\beta because:

  • Higher β\beta raises the monitoring cost, reducing Xs∗X_s^* thresholds

  • Fewer borrowers are served, and each absorbs different equity

We solve Kdemand(β∗)=KtotalK_{demand}(\beta^*) = K_{total} for the equilibrium.

Source
def total_capital_demand(beta, A_nbhds, N_pop=100, X_lo=160, X_hi=240):
    """Aggregate capital demand across all neighborhoods for a given beta."""
    bank = BankHetX(A_nbhds, beta=beta, X_lo=X_lo, X_hi=X_hi, N_pop=N_pop)
    return bank.capital_demand_grid(A_nbhds, N_potential=N_pop)


def total_borrowers_served(beta, A_nbhds, N_pop=100, X_lo=160, X_hi=240):
    """Total borrowers served across all neighborhoods for a given beta."""
    total = 0.0
    for A_j in A_nbhds:
        bank = BankHetX(np.array([A_j]), beta=beta, X_lo=X_lo, X_hi=X_hi, N_pop=N_pop)
        total += bank.N_served(A_j)
    return total


def total_surplus(beta, A_nbhds, N_pop=100, X_lo=160, X_hi=240):
    """Total borrower surplus across all neighborhoods for a given beta."""
    total = 0.0
    for A_j in A_nbhds:
        bank = BankHetX(np.array([A_j]), beta=beta, X_lo=X_lo, X_hi=X_hi, N_pop=N_pop)
        total += bank.total_surplus_nbhd(A_j)
    return total


# ---- Compute K_demand schedule ----
A_nbhds_eq = np.linspace(10, 140, 50)  # 50 neighborhoods
N_pop = 100  # borrowers per neighborhood

beta_range = np.linspace(1.0, 2.0, 80)
Kd_schedule = [total_capital_demand(b, A_nbhds_eq, N_pop) for b in beta_range]
N_schedule = [total_borrowers_served(b, A_nbhds_eq, N_pop) for b in beta_range]
S_schedule = [total_surplus(b, A_nbhds_eq, N_pop) for b in beta_range]

print(f"K_demand range: [{min(Kd_schedule):.0f}, {max(Kd_schedule):.0f}]")
print(f"N_served range: [{min(N_schedule):.0f}, {max(N_schedule):.0f}]")
K_demand range: [11367, 574316]
N_served range: [568, 4616]
Source
# ---- Solve for equilibrium at several K_total levels ----
# Choose K_total values that span a meaningful range

K_totals = [20000, 40000, 80000, 160000, 320000]
equilibria = {}

for K_tot in K_totals:
    def eq_func(beta):
        return total_capital_demand(beta, A_nbhds_eq, N_pop) - K_tot
    
    # Check if equilibrium exists within [1.0, 2.0]
    kd_low = total_capital_demand(1.0, A_nbhds_eq, N_pop)
    kd_high = total_capital_demand(2.0, A_nbhds_eq, N_pop)
    
    if K_tot > kd_low:
        print(f"K_total = {K_tot:>6}: Capital oversupply (K_demand at beta=1.0 is {kd_low:.0f}). beta* = 1.0")
        equilibria[K_tot] = {'beta': 1.0, 'N': total_borrowers_served(1.0, A_nbhds_eq, N_pop),
                             'surplus': total_surplus(1.0, A_nbhds_eq, N_pop)}
    elif K_tot < kd_high:
        print(f"K_total = {K_tot:>6}: Capital too scarce (K_demand at beta=2.0 is {kd_high:.0f}). Extend range.")
    else:
        sol = brentq(eq_func, 1.0, 2.0)
        n_tot = total_borrowers_served(sol, A_nbhds_eq, N_pop)
        s_tot = total_surplus(sol, A_nbhds_eq, N_pop)
        equilibria[K_tot] = {'beta': sol, 'N': n_tot, 'surplus': s_tot}
        print(f"K_total = {K_tot:>6}: beta* = {sol:.4f}, N_total = {n_tot:.0f}, Total Surplus = {s_tot:.0f}")

print("\n--- Equilibrium Summary ---")
print(f"{'K_total':>10} {'beta*':>8} {'N_total':>10} {'Surplus':>10}")
for K, eq in sorted(equilibria.items()):
    print(f"{K:>10} {eq['beta']:>8.4f} {eq['N']:>10.0f} {eq['surplus']:>10.0f}")
K_total =  20000: beta* = 1.6650, N_total = 916, Total Surplus = 83084
K_total =  40000: beta* = 1.5242, N_total = 1183, Total Surplus = 94395
K_total =  80000: beta* = 1.3861, N_total = 1600, Total Surplus = 111568
K_total = 160000: beta* = 1.2627, N_total = 2234, Total Surplus = 137017
K_total = 320000: beta* = 1.1457, N_total = 3221, Total Surplus = 179842

--- Equilibrium Summary ---
   K_total    beta*    N_total    Surplus
     20000   1.6650        916      83084
     40000   1.5242       1183      94395
     80000   1.3861       1600     111568
    160000   1.2627       2234     137017
    320000   1.1457       3221     179842
Source
# ---- Visualization: Capital Market Clearing ----

fig, axes = plt.subplots(1, 3, figsize=(18, 5.5))

# Plot 1: K_demand(beta) with K_total lines
ax = axes[0]
ax.plot(Kd_schedule, beta_range, 'k-', linewidth=2.5, label='$K_{demand}(\\beta)$')
for K_tot, color in zip(K_totals, cm.viridis(np.linspace(0.2, 0.9, len(K_totals)))):
    ax.axvline(x=K_tot, color=color, linestyle='--', alpha=0.8, label=f'$K_{{total}}={K_tot:,}$')
    if K_tot in equilibria:
        b = equilibria[K_tot]['beta']
        ax.plot(K_tot, b, 'o', color=color, markersize=8)
ax.set_xlabel('Total MFI Capital')
ax.set_ylabel(r'Return on equity $\beta$')
ax.set_title('Capital Market Clearing', weight='bold')
ax.legend(fontsize=8)

# Plot 2: N_served(beta)
ax = axes[1]
ax.plot(beta_range, N_schedule, 'teal', linewidth=2.5)
for K_tot in K_totals:
    if K_tot in equilibria:
        b = equilibria[K_tot]['beta']
        n = equilibria[K_tot]['N']
        ax.plot(b, n, 'ro', markersize=7)
        ax.annotate(f'K={K_tot//1000}k', (b, n), textcoords='offset points',
                   xytext=(5, 5), fontsize=8)
ax.set_xlabel(r'Return on equity $\beta$')
ax.set_ylabel('Total borrowers served')
ax.set_title('Borrower Outreach vs. ROE', weight='bold')

# Plot 3: Surplus(beta)
ax = axes[2]
ax.plot(beta_range, S_schedule, 'darkorange', linewidth=2.5)
for K_tot in K_totals:
    if K_tot in equilibria:
        b = equilibria[K_tot]['beta']
        s = equilibria[K_tot]['surplus']
        ax.plot(b, s, 'ro', markersize=7)
        ax.annotate(f'K={K_tot//1000}k', (b, s), textcoords='offset points',
                   xytext=(5, 5), fontsize=8)
        ax.set_xlabel(r'Return on equity $\beta$')
ax.set_ylabel('Total borrower surplus')
ax.set_title('Welfare vs. ROE', weight='bold')

plt.suptitle('General Equilibrium with Heterogeneous Projects', 
             fontsize=14, weight='bold', y=1.02)
plt.tight_layout()
plt.savefig('figs/fig-GE-clearing-hetX.png', dpi=300, bbox_inches='tight')
<Figure size 1800x550 with 3 Axes>

6. Depth vs. Breadth: The Two-Margin Decomposition

A key new result: when we inject additional capital ΔK\Delta K into the economy, lending expands on two margins simultaneously:

  1. Extensive margin (breadth): New neighborhoods become served that were previously excluded

  2. Intensive margin (depth): Within already-served neighborhoods, more marginal borrowers (lower XsX_s) now qualify

Let’s decompose the impact of a capital increase by comparing the equilibrium allocation across neighborhoods at different KtotalK_{total} levels.

Source
# Depth vs Breadth decomposition: compare two equilibria
# Pick two K_total values that have interior equilibria

K_low = None
K_high = None
for K in sorted(equilibria.keys()):
    if equilibria[K]['beta'] > 1.0:
        if K_low is None:
            K_low = K
        else:
            K_high = K

if K_low is None or K_high is None:
    # Fallback: just pick two
    K_candidates = sorted([k for k, v in equilibria.items() if v['beta'] > 1.0])
    if len(K_candidates) >= 2:
        K_low, K_high = K_candidates[0], K_candidates[-1]
    else:
        print("Need at least two interior equilibria. Adjusting K_totals...")
        K_low, K_high = 10000, 40000

print(f"Comparing K_total = {K_low} vs {K_high}")

beta_low = equilibria[K_low]['beta'] if K_low in equilibria else 1.5
beta_high = equilibria[K_high]['beta'] if K_high in equilibria else 1.1

# Compute neighborhood-level allocations for both equilibria
A_fine = np.linspace(5, 140, 80)

data_low = {'frac': [], 'N': [], 'K': [], 'surplus': [], 'Xstar': []}
data_high = {'frac': [], 'N': [], 'K': [], 'surplus': [], 'Xstar': []}

for A_j in A_fine:
    for beta_v, data in [(beta_low, data_low), (beta_high, data_high)]:
        bank = BankHetX(np.array([A_j]), beta=beta_v, N_pop=N_pop)
        data['frac'].append(bank.frac_served(A_j))
        data['N'].append(bank.N_served(A_j))
        data['K'].append(bank.K_demand_nbhd(A_j))
        data['surplus'].append(bank.total_surplus_nbhd(A_j))
        data['Xstar'].append(bank.X_star(A_j))

# Convert to arrays
for d in [data_low, data_high]:
    for k in d:
        d[k] = np.array(d[k])

# Plot the decomposition
fig, axes = plt.subplots(2, 2, figsize=(15, 11))

ax = axes[0, 0]
ax.fill_between(A_fine, 0, data_low['frac'], alpha=0.3, color='blue', 
                label=f'Low K ({K_low:,}), $\\beta^*$={beta_low:.3f}')
ax.fill_between(A_fine, data_low['frac'], data_high['frac'], alpha=0.3, color='green',
                label=f'Gained from more K')
ax.plot(A_fine, data_high['frac'], 'g-', linewidth=2,
        label=f'High K ({K_high:,}), $\\beta^*$={beta_high:.3f}')
ax.plot(A_fine, data_low['frac'], 'b-', linewidth=2)
ax.set_ylabel('Fraction served')
ax.set_title('Population Served: Depth Expansion', weight='bold')
ax.legend(fontsize=8)

ax = axes[0, 1]
ax.plot(A_fine, data_low['Xstar'], 'b-', linewidth=2, label=f'$\\beta^*$={beta_low:.3f}')
ax.plot(A_fine, data_high['Xstar'], 'g-', linewidth=2, label=f'$\\beta^*$={beta_high:.3f}')
ax.axhline(y=120, color='grey', linestyle=':', alpha=0.5)
ax.axhline(y=280, color='grey', linestyle=':', alpha=0.5)
ax.set_ylabel('Marginal cutoff $X_s^*$')
ax.set_title('Project Quality Threshold: Lower = More Inclusive', weight='bold')
ax.legend(fontsize=9)

ax = axes[1, 0]
delta_N = data_high['N'] - data_low['N']
ax.bar(A_fine, data_low['N'], width=1.5, color='blue', alpha=0.5, label='Existing borrowers')
ax.bar(A_fine, delta_N, width=1.5, bottom=data_low['N'], color='green', alpha=0.5, label='New borrowers')
ax.set_ylabel('Number of borrowers')
ax.set_title('Borrower Count: Extensive + Intensive Margin', weight='bold')
ax.legend(fontsize=9)

ax = axes[1, 1]
delta_S = data_high['surplus'] - data_low['surplus']
ax.plot(A_fine, delta_S, 'darkred', linewidth=2.5)
ax.fill_between(A_fine, 0, delta_S, alpha=0.2, color='red')
ax.set_ylabel('$\\Delta$ Total surplus')
ax.set_title(f'Marginal Value of Capital Injection ($\\Delta K = {K_high - K_low:,}$)', weight='bold')
ax.axhline(y=0, color='grey', linestyle='-', alpha=0.3)

for ax in axes.flat:
    ax.set_xlabel('Neighborhood collateral $A_j$')
    ax.set_xlim(0, 140)

fig.suptitle('Depth vs. Breadth: Two-Margin Impact of Capital Injection', 
             fontsize=14, weight='bold', y=1.01)
plt.tight_layout()
plt.savefig('figs/fig-depth-vs-breadth.png', dpi=300, bbox_inches='tight')
plt.show()
Comparing K_total = 20000 vs 320000
<Figure size 1500x1100 with 4 Axes>

7. Mission Drift Under Commercial Pressure

One of the most important implications of this model is a precise formalization of mission drift. When the return on equity β\beta rises (e.g., because a social MFI is taken over by commercial investors, or because the overall capital supply shrinks):

  1. Cream-skimming within neighborhoods: Xs∗X_s^* rises, so only the best projects within each neighborhood get funded

  2. Neighborhood abandonment: Poorer neighborhoods with low AjA_j become entirely unserved

  3. Capital reallocation: Equity migrates from poor to rich neighborhoods

Let’s visualize this “mission drift” as β\beta increases from the social benchmark to a fully commercial level.

Source
# Mission drift visualization: heatmap of service coverage

beta_mission = np.linspace(1.0, 1.8, 50)
A_mission = np.linspace(5, 140, 60)

# Build a matrix: frac_served[beta_idx, A_idx]
frac_matrix = np.zeros((len(beta_mission), len(A_mission)))
K_matrix = np.zeros_like(frac_matrix)

for i, b in enumerate(beta_mission):
    for j, A_j in enumerate(A_mission):
        bank = BankHetX(np.array([A_j]), beta=b, N_pop=N_pop)
        frac_matrix[i, j] = bank.frac_served(A_j)
        K_matrix[i, j] = bank.K_demand_nbhd(A_j)

fig, axes = plt.subplots(1, 2, figsize=(16, 6))

# Heatmap 1: Fraction served
im1 = axes[0].imshow(frac_matrix, aspect='auto', origin='lower',
                     extent=[A_mission[0], A_mission[-1], beta_mission[0], beta_mission[-1]],
                     cmap='YlGnBu', vmin=0, vmax=1)
axes[0].set_xlabel('Neighborhood collateral $A_j$', fontsize=11)
axes[0].set_ylabel(r'Return on equity $\beta$', fontsize=11)
axes[0].set_title('Fraction of Population Served', weight='bold', fontsize=12)
fig.colorbar(im1, ax=axes[0], label='Fraction served', shrink=0.8)

# Mark equilibria
for K_tot, eq in sorted(equilibria.items()):
    if eq['beta'] > 1.0:
        axes[0].axhline(y=eq['beta'], color='red', linestyle='--', alpha=0.5)
        axes[0].text(A_mission[-1]+2, eq['beta'], f"K={K_tot//1000}k", fontsize=8, 
                    va='center', color='red')

# Heatmap 2: Capital allocated
im2 = axes[1].imshow(K_matrix, aspect='auto', origin='lower',
                     extent=[A_mission[0], A_mission[-1], beta_mission[0], beta_mission[-1]],
                     cmap='YlOrRd')
axes[1].set_xlabel('Neighborhood collateral $A_j$', fontsize=11)
axes[1].set_ylabel(r'Return on equity $\beta$', fontsize=11)
axes[1].set_title('MFI Equity Capital per Neighborhood', weight='bold', fontsize=12)
fig.colorbar(im2, ax=axes[1], label='$K_j$ demanded', shrink=0.8)

fig.suptitle('Mission Drift: How Rising ROE Reshapes the Lending Landscape', 
             fontsize=14, weight='bold', y=1.02)
plt.tight_layout()
plt.savefig('figs/fig-mission-drift-heatmap.png', dpi=300, bbox_inches='tight')
plt.show()
<Figure size 1600x600 with 4 Axes>

8. Social Investor Impact: Targeted vs. Untargeted Capital Injection

Suppose a social investor has ΔK\Delta K of capital to inject into the economy. Two strategies:

Strategy A (Untargeted): Add ΔK\Delta K to the general pool. This lowers β∗\beta^* economy-wide, benefiting all neighborhoods.

Strategy B (Targeted to poor neighborhoods): Inject ΔK\Delta K as concessional equity (βsubsidy<β∗\beta_{subsidy} < \beta^*) in specific poor neighborhoods. This directly expands access there without changing β∗\beta^* elsewhere.

Let’s compare the total impact (borrowers reached, surplus generated) of each strategy.

Source
# Social investor impact analysis
# Pick a baseline K_total and compute a reference equilibrium

# Use the middle K_total as baseline
K_baseline = sorted([k for k, v in equilibria.items() if v['beta'] > 1.01])[0]
beta_baseline = equilibria[K_baseline]['beta']
N_baseline = equilibria[K_baseline]['N']
S_baseline = equilibria[K_baseline]['surplus']

print(f"Baseline: K_total = {K_baseline:,}, beta* = {beta_baseline:.4f}")
print(f"          N_total = {N_baseline:.0f}, Surplus = {S_baseline:.0f}")
print()

# Inject amounts
delta_Ks = np.linspace(0, K_baseline * 0.5, 20)

# Strategy A: Untargeted (increase K_total)
results_A = {'N': [], 'S': [], 'beta': []}
for dK in delta_Ks:
    K_new = K_baseline + dK
    try:
        kd_at_1 = total_capital_demand(1.0, A_nbhds_eq, N_pop)
        if K_new >= kd_at_1:
            b = 1.0
        else:
            b = brentq(lambda beta: total_capital_demand(beta, A_nbhds_eq, N_pop) - K_new,
                       1.0, 2.0)
    except:
        b = beta_baseline
    results_A['beta'].append(b)
    results_A['N'].append(total_borrowers_served(b, A_nbhds_eq, N_pop))
    results_A['S'].append(total_surplus(b, A_nbhds_eq, N_pop))

# Strategy B: Targeted to poorest 10 neighborhoods at beta=1.0 (social equity)
# The rest of the economy still clears at beta*
# Approximate: in poor neighborhoods, use beta=1.0; elsewhere, beta = beta_baseline
results_B = {'N': [], 'S': []}
A_poor = A_nbhds_eq[:10]  # poorest 10 neighborhoods
A_rest = A_nbhds_eq[10:]  # remaining neighborhoods

for dK in delta_Ks:
    # Poor neighborhoods get subsidized equity at beta=1.0
    N_poor = 0
    S_poor = 0
    for A_j in A_poor:
        bank = BankHetX(np.array([A_j]), beta=1.0, N_pop=N_pop)
        N_poor += bank.N_served(A_j)
        S_poor += bank.total_surplus_nbhd(A_j)
    
    # Rest of economy at beta_baseline (unchanged)
    N_rest = 0
    S_rest = 0
    for A_j in A_rest:
        bank = BankHetX(np.array([A_j]), beta=beta_baseline, N_pop=N_pop)
        N_rest += bank.N_served(A_j)
        S_rest += bank.total_surplus_nbhd(A_j)
    
    results_B['N'].append(N_poor + N_rest)
    results_B['S'].append(S_poor + S_rest)

# Plot comparison
fig, axes = plt.subplots(1, 2, figsize=(14, 5.5))

ax = axes[0]
ax.plot(delta_Ks, np.array(results_A['N']) - N_baseline, 'b-', linewidth=2.5, 
        label='A: Untargeted (lower $\\beta^*$)')
ax.plot(delta_Ks, np.array(results_B['N']) - N_baseline, 'r--', linewidth=2.5,
        label='B: Targeted to poor nbhds ($\\beta=1.0$)')
ax.set_xlabel('Capital injection $\\Delta K$')
ax.set_ylabel('$\\Delta$ Borrowers served')
ax.set_title('New Borrowers Reached', weight='bold')
ax.legend(fontsize=10)
ax.axhline(y=0, color='grey', linestyle='-', alpha=0.3)

ax = axes[1]
ax.plot(delta_Ks, np.array(results_A['S']) - S_baseline, 'b-', linewidth=2.5,
        label='A: Untargeted')
ax.plot(delta_Ks, np.array(results_B['S']) - S_baseline, 'r--', linewidth=2.5,
        label='B: Targeted to poor')
ax.set_xlabel('Capital injection $\\Delta K$')
ax.set_ylabel('$\\Delta$ Total surplus')
ax.set_title('Surplus Generated', weight='bold')
ax.legend(fontsize=10)
ax.axhline(y=0, color='grey', linestyle='-', alpha=0.3)

fig.suptitle('Social Investor Strategies: Untargeted vs. Targeted Capital', 
             fontsize=14, weight='bold', y=1.02)
plt.tight_layout()
plt.savefig('figs/fig-social-investor-strategies.png', dpi=300, bbox_inches='tight')
plt.show()
Baseline: K_total = 20,000, beta* = 1.6650
          N_total = 916, Surplus = 83084

<Figure size 1400x550 with 2 Axes>

9. Analytical Results: Closed-Form Expressions

Proposition: Marginal Project Cutoff

For a neighborhood with collateral AjA_j and a uniform distribution Xs∼U[X‾,Xˉ]X_s \sim U[\underline{X}, \bar{X}], the marginal project cutoff under leveraged lending with equity return β\beta is:

Xs∗(Aj,β)=γI+f+c(β)⋅κ(β)⋅[pB0Δ+γI+f−Aj]p[1+c(β)⋅κ(β)]X_s^*(A_j, \beta) = \frac{\gamma I + f + c(\beta) \cdot \kappa(\beta) \cdot \left[\frac{pB_0}{\Delta} + \gamma I + f - A_j\right]}{p\left[1 + c(\beta) \cdot \kappa(\beta)\right]}

where:

  • c(β)=βΔ(α−1)βp+γqc(\beta) = \frac{\beta \Delta}{(\alpha - 1)\beta p + \gamma q} is the monitoring sensitivity

  • κ(β)=1+(β−γ)βqΔ\kappa(\beta) = 1 + \frac{(\beta - \gamma)}{\beta} \frac{q}{\Delta} is the effective monitoring cost multiplier

Comparative Statics

Proposition: ∂Xs∗∂β>0\frac{\partial X_s^*}{\partial \beta} > 0 and ∂Xs∗∂Aj<0\frac{\partial X_s^*}{\partial A_j} < 0.

  • Higher β\beta raises the cutoff: cream-skimming intensifies under commercial pressure

  • Higher AjA_j lowers the cutoff: richer neighborhoods can fund more marginal projects

Fraction Served

Under the uniform distribution:

frac_served(Aj,β)=Xˉ−Xs∗(Aj,β)Xˉ−X‾\text{frac\_served}(A_j, \beta) = \frac{\bar{X} - X_s^*(A_j, \beta)}{\bar{X} - \underline{X}}

Total Capital Demand (Analytical)

For the leveraged regime, Im(Aj,Xs)=qβΔ⋅m∗(Aj,Xs)I^m(A_j, X_s) = \frac{q}{\beta \Delta} \cdot m^*(A_j, X_s). Since m∗m^* is linear in XsX_s, the integral over the uniform distribution has a closed form:

Kj(β)=NpopXˉ−X‾⋅qβΔ⋅c(β)2⋅[pB0Δ+γI+f−Aj−p⋅Xs∗]⋅[Xnm−Xs∗]K_j(\beta) = \frac{N_{pop}}{\bar{X} - \underline{X}} \cdot \frac{q}{\beta \Delta} \cdot \frac{c(\beta)}{2} \cdot \left[\frac{pB_0}{\Delta} + \gamma I + f - A_j - p \cdot X_s^*\right] \cdot \left[X_{nm} - X_s^*\right]

where Xnm(Aj)=pB0/Δ+γI+f−AjpX_{nm}(A_j) = \frac{pB_0/\Delta + \gamma I + f - A_j}{p} is the threshold above which no monitoring is needed.

Let’s verify these analytical expressions against our numerical integration.

Source
# Compare analytical vs numerical for several (A_j, beta) pairs using refactored BankHetX methods
print("=== Verification: Analytical vs Numerical K_demand ===")
print(f"{'A_j':>6} {'beta':>6} {'K_numerical':>12} {'K_analytical':>14} {'Match':>8}")
print("-" * 50)

for A_j in [30, 60, 90, 120]:
    for beta_v in [1.0, 1.2, 1.5]:
        bank = BankHetX(np.array([A_j]), beta=beta_v, N_pop=N_pop)
        K_num = bank.K_demand_nbhd(A_j, method='numerical')
        K_ana = bank.K_demand_nbhd(A_j, method='analytical')
        match = abs(K_num - K_ana) < 0.1 * max(K_num, 1.0)
        print(f"{A_j:>6} {beta_v:>6.1f} {K_num:>12.2f} {K_ana:>14.2f} {'OK' if match else 'DIFF':>8}")
=== Verification: Analytical vs Numerical K_demand ===
   A_j   beta  K_numerical   K_analytical    Match
--------------------------------------------------
    30    1.0     17703.68       17703.68       OK
    30    1.2      4574.24        4574.24       OK
    30    1.5         0.00           0.00       OK
    60    1.0     14641.42       14641.42       OK
    60    1.2      6534.13        6534.13       OK
    60    1.5         0.00           0.00       OK
    90    1.0      9465.42        9465.42       OK
    90    1.2      5338.23        5338.23       OK
    90    1.5      1566.38        1566.38       OK
   120    1.0      4374.33        4374.33       OK
   120    1.2      3812.83        3812.83       OK
   120    1.5      1748.16        1748.16       OK

10. Comparative Statics: How Distribution Width Affects Equilibrium

An important modeling choice is the width of the project return distribution [X‾,Xˉ][\underline{X}, \bar{X}]. As the distribution collapses to a point (X‾→Xˉ→X\underline{X} \to \bar{X} \to X), we should recover the homogeneous baseline. Let’s explore how the distribution’s spread affects the equilibrium.

Source
# Effect of distribution width on equilibrium
# Keep mean fixed at 200, vary the spread

X_mean = 200
spreads = [10, 40, 80, 120, 160]  # half-width of uniform distribution
K_target = 20000

print("=== Effect of Project Return Heterogeneity ===")
print(f"{'Spread':>8} {'X_lo':>6} {'X_hi':>6} {'beta*':>8} {'N_total':>10} {'Surplus':>10}")
print("-" * 55)

results_spread = []

for spread in spreads:
    X_lo_s = X_mean - spread
    X_hi_s = X_mean + spread
    
    # Compute K_demand schedule for this spread
    def kd_spread(beta):
        tot = 0.0
        for A_j in A_nbhds_eq:
            bank = BankHetX(np.array([A_j]), beta=beta, X_lo=X_lo_s, X_hi=X_hi_s, N_pop=N_pop)
            tot += bank.K_demand_nbhd(A_j)
        return tot - K_target
    
    kd_at_1 = kd_spread(1.0) + K_target
    if K_target >= kd_at_1:
        b_star = 1.0
    else:
        try:
            b_star = brentq(kd_spread, 1.0, 3.0)
        except:
            b_star = float('nan')
    
    n_tot = total_borrowers_served(b_star, A_nbhds_eq, N_pop)
    # Recompute with correct X range
    n_tot = 0
    s_tot = 0
    for A_j in A_nbhds_eq:
        bank = BankHetX(np.array([A_j]), beta=b_star, X_lo=X_lo_s, X_hi=X_hi_s, N_pop=N_pop)
        n_tot += bank.N_served(A_j)
        s_tot += bank.total_surplus_nbhd(A_j)
    
    results_spread.append({'spread': spread, 'X_lo': X_lo_s, 'X_hi': X_hi_s,
                           'beta': b_star, 'N': n_tot, 'surplus': s_tot})
    print(f"{spread:>8} {X_lo_s:>6} {X_hi_s:>6} {b_star:>8.4f} {n_tot:>10.0f} {s_tot:>10.0f}")

print("\nKey insight: wider distribution => more cream-skimming => fewer borrowers")
print("served at equilibrium, but those served have higher average project quality.")
=== Effect of Project Return Heterogeneity ===
  Spread   X_lo   X_hi    beta*    N_total    Surplus
-------------------------------------------------------
      10    190    210   1.6506        872      63276
      40    160    240   1.6650        916      83084
      80    120    280   1.8462       1000     120109
     120     80    320   2.2628       1000     148932
     160     40    360   2.9860       1000     177977

Key insight: wider distribution => more cream-skimming => fewer borrowers
served at equilibrium, but those served have higher average project quality.

11. Summary and Conclusions

What We’ve Shown

  1. Within-neighborhood heterogeneity in project returns creates a smooth intensive margin: The MFI optimally serves borrowers with Xs≥Xs∗(Aj,β)X_s \geq X_s^*(A_j, \beta), and the cutoff rises with β\beta (the return on equity) and falls with AjA_j (neighborhood wealth).

  2. Both N and K are endogenous: The number of borrowers is determined by the cutoff Xs∗X_s^*, and capital flows across neighborhoods seeking the highest return. The equilibrium β∗\beta^* clears both markets simultaneously.

  3. Mission drift is formalized: An increase in β\beta causes both cream-skimming within neighborhoods (intensive margin) and abandonment of poor neighborhoods (extensive margin). This is the first formal model to capture both channels.

  4. Donor targeting has richer tradeoffs: Untargeted capital injections lower β∗\beta^* everywhere, benefiting all neighborhoods. Targeted injections at concessional rates can directly serve excluded populations without affecting the commercial equilibrium.

  5. Analytical tractability: The uniform distribution yields closed-form expressions for all key objects (Xs∗X_s^*, KjK_j, NjN_j), enabling clean comparative statics propositions.

Directions for Extension

  • Re-introduce F>0F > 0: Fixed entry costs create a discrete entry/exit decision per neighborhood, amplifying the extensive margin

  • Non-uniform distributions: Pareto or log-normal project returns would better capture empirical patterns

  • Endogenous γ\gamma: Allow the cost of uninformed capital to respond to the overall lending portfolio risk

  • Borrower heterogeneity in AA: Combine within-neighborhood AA and XsX_s distributions for the most general model