feat(mean_field): Implement Mean Field Games module with PDE solvers
- Add complete mean_field module with 6 submodules - Implement HJB and Fokker-Planck PDE solvers with rayon parallelization - Add forward-backward fixed-point iteration algorithm - Include Nash equilibrium and optimal transport utilities - Add comprehensive Jupyter notebook tutorial with: * Mathematical formulation (HJB and FP equations) * Finite difference methods explanation * Complete congestion game example * 3D visualizations and convergence plots * Citations to Jiang, Chewi, Pooladian (2023) paper - All tests passing (5 tests in mean_field module) - Based on 'Numerical Methods for Mean Field Games' PDF algorithms
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "d18b6d0c",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Setup\n",
|
||||
"\n",
|
||||
"First, let's import the necessary libraries and set up the environment."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "d33ec4c7",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import numpy as np\n",
|
||||
"import matplotlib.pyplot as plt\n",
|
||||
"from matplotlib import cm\n",
|
||||
"from mpl_toolkits.mplot3d import Axes3D\n",
|
||||
"import seaborn as sns\n",
|
||||
"\n",
|
||||
"# Set plotting style\n",
|
||||
"sns.set_style('whitegrid')\n",
|
||||
"plt.rcParams['figure.figsize'] = (12, 8)\n",
|
||||
"plt.rcParams['font.size'] = 11\n",
|
||||
"\n",
|
||||
"print(\"✓ Libraries loaded\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "fb55613b",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Example 1: Congestion Game\n",
|
||||
"\n",
|
||||
"Consider a simple congestion game where agents want to move from an initial distribution to a target distribution, but experience congestion costs.\n",
|
||||
"\n",
|
||||
"### Problem Formulation\n",
|
||||
"\n",
|
||||
"- **Hamiltonian**: $H(x,p) = \\frac{1}{2}|p|^2$ (quadratic control cost)\n",
|
||||
"- **Running cost**: $f(x,m) = \\lambda m(x,t)$ (congestion penalty)\n",
|
||||
"- **Terminal cost**: $g(x,m(T)) = \\frac{1}{2}(x - x_{\\text{target}})^2$\n",
|
||||
"- **Initial distribution**: $m_0(x) = \\mathcal{N}(0.3, 0.05^2)$\n",
|
||||
"\n",
|
||||
"### Numerical Solution\n",
|
||||
"\n",
|
||||
"We solve this using finite difference methods with:\n",
|
||||
"- Spatial domain: $\\Omega = [0, 1]$\n",
|
||||
"- Time horizon: $T = 1.0$\n",
|
||||
"- Grid: $n_x = 100$, $n_t = 100$\n",
|
||||
"- Viscosity: $\\nu = 0.01$"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "4e31056d",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Problem parameters\n",
|
||||
"nx = 100 # Spatial grid points\n",
|
||||
"nt = 100 # Time steps\n",
|
||||
"T = 1.0 # Time horizon\n",
|
||||
"nu = 0.01 # Viscosity\n",
|
||||
"lambda_congestion = 0.5 # Congestion penalty\n",
|
||||
"x_target = 0.7 # Target location\n",
|
||||
"\n",
|
||||
"# Spatial and temporal grids\n",
|
||||
"x = np.linspace(0, 1, nx)\n",
|
||||
"t = np.linspace(0, T, nt)\n",
|
||||
"dx = x[1] - x[0]\n",
|
||||
"dt = t[1] - t[0]\n",
|
||||
"\n",
|
||||
"# Initial distribution: Gaussian centered at 0.3\n",
|
||||
"m0 = np.exp(-((x - 0.3)**2) / (2 * 0.05**2))\n",
|
||||
"m0 /= np.sum(m0) * dx # Normalize\n",
|
||||
"\n",
|
||||
"# Plot initial distribution\n",
|
||||
"plt.figure(figsize=(10, 4))\n",
|
||||
"plt.plot(x, m0, 'b-', linewidth=2, label='Initial distribution $m_0(x)$')\n",
|
||||
"plt.axvline(x_target, color='r', linestyle='--', label=f'Target: $x={x_target}$')\n",
|
||||
"plt.xlabel('Space $x$')\n",
|
||||
"plt.ylabel('Density')\n",
|
||||
"plt.title('Initial Agent Distribution')\n",
|
||||
"plt.legend()\n",
|
||||
"plt.grid(True, alpha=0.3)\n",
|
||||
"plt.tight_layout()\n",
|
||||
"plt.show()\n",
|
||||
"\n",
|
||||
"print(f\"Grid: {nx} × {nt}\")\n",
|
||||
"print(f\"dx = {dx:.4f}, dt = {dt:.4f}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "dd49d343",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"### Fixed-Point Iteration Algorithm\n",
|
||||
"\n",
|
||||
"The algorithm proceeds as follows:\n",
|
||||
"\n",
|
||||
"1. **Initialize**: Start with uniform distribution $m^{(0)}(x,t) = 1$\n",
|
||||
"2. **Iterate** until convergence:\n",
|
||||
" - Solve HJB backward: $-\\partial_t u^{(k)} - \\nu \\Delta u^{(k)} + \\frac{1}{2}|\\nabla u^{(k)}|^2 = \\lambda m^{(k-1)}$\n",
|
||||
" - Solve FP forward: $\\partial_t m^{(k)} - \\nu \\Delta m^{(k)} - \\text{div}(m^{(k)} \\nabla u^{(k)}) = 0$\n",
|
||||
" - Update: $m^{(k)} \\leftarrow \\alpha m^{(k)} + (1-\\alpha) m^{(k-1)}$ (relaxation)\n",
|
||||
"3. **Check** convergence: $\\|m^{(k)} - m^{(k-1)}\\|_{L^2} < \\epsilon$\n",
|
||||
"\n",
|
||||
"This is implemented using:\n",
|
||||
"- Upwind finite differences for first derivatives\n",
|
||||
"- Central differences for second derivatives\n",
|
||||
"- Implicit time stepping for stability"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "b421e735",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"def solve_hjb(m, u_T):\n",
|
||||
" \"\"\"Solve HJB equation backward in time\"\"\"\n",
|
||||
" u = np.zeros((nx, nt))\n",
|
||||
" u[:, -1] = u_T # Terminal condition\n",
|
||||
" \n",
|
||||
" for n in range(nt-2, -1, -1):\n",
|
||||
" for i in range(1, nx-1):\n",
|
||||
" # Laplacian (central difference)\n",
|
||||
" u_xx = (u[i+1, n+1] - 2*u[i, n+1] + u[i-1, n+1]) / dx**2\n",
|
||||
" \n",
|
||||
" # Hamiltonian with upwind scheme\n",
|
||||
" u_x_plus = (u[i+1, n+1] - u[i, n+1]) / dx\n",
|
||||
" u_x_minus = (u[i, n+1] - u[i-1, n+1]) / dx\n",
|
||||
" H = 0.5 * min(u_x_plus**2, u_x_minus**2) # Upwind\n",
|
||||
" \n",
|
||||
" # Running cost\n",
|
||||
" f = lambda_congestion * m[i, n]\n",
|
||||
" \n",
|
||||
" # Update (implicit Euler)\n",
|
||||
" u[i, n] = u[i, n+1] - dt * (nu * u_xx - H + f)\n",
|
||||
" \n",
|
||||
" # Boundary conditions (Neumann)\n",
|
||||
" u[0, n] = u[1, n]\n",
|
||||
" u[-1, n] = u[-2, n]\n",
|
||||
" \n",
|
||||
" return u\n",
|
||||
"\n",
|
||||
"def solve_fp(u, m0):\n",
|
||||
" \"\"\"Solve Fokker-Planck equation forward in time\"\"\"\n",
|
||||
" m = np.zeros((nx, nt))\n",
|
||||
" m[:, 0] = m0 # Initial condition\n",
|
||||
" \n",
|
||||
" for n in range(nt-1):\n",
|
||||
" for i in range(1, nx-1):\n",
|
||||
" # Laplacian\n",
|
||||
" m_xx = (m[i+1, n] - 2*m[i, n] + m[i-1, n]) / dx**2\n",
|
||||
" \n",
|
||||
" # Velocity field\n",
|
||||
" u_x = (u[i+1, n] - u[i-1, n]) / (2*dx)\n",
|
||||
" v = u_x # For quadratic Hamiltonian: H_p = p\n",
|
||||
" \n",
|
||||
" # Upwind for advection\n",
|
||||
" if v > 0:\n",
|
||||
" flux_diff = v * (m[i, n] - m[i-1, n]) / dx\n",
|
||||
" else:\n",
|
||||
" flux_diff = v * (m[i+1, n] - m[i, n]) / dx\n",
|
||||
" \n",
|
||||
" # Update (forward Euler)\n",
|
||||
" m[i, n+1] = m[i, n] + dt * (nu * m_xx - flux_diff)\n",
|
||||
" m[i, n+1] = max(m[i, n+1], 0) # Non-negativity\n",
|
||||
" \n",
|
||||
" # Boundary conditions\n",
|
||||
" m[0, n+1] = m[1, n+1]\n",
|
||||
" m[-1, n+1] = m[-2, n+1]\n",
|
||||
" \n",
|
||||
" # Normalize\n",
|
||||
" m[:, n+1] /= (np.sum(m[:, n+1]) * dx)\n",
|
||||
" \n",
|
||||
" return m\n",
|
||||
"\n",
|
||||
"print(\"✓ Solver functions defined\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "6ffb4f23",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Fixed-point iteration\n",
|
||||
"max_iter = 50\n",
|
||||
"tol = 1e-5\n",
|
||||
"relax = 0.5\n",
|
||||
"\n",
|
||||
"# Initialize\n",
|
||||
"m_old = np.ones((nx, nt)) / nx\n",
|
||||
"errors = []\n",
|
||||
"\n",
|
||||
"print(\"Running fixed-point iteration...\")\n",
|
||||
"for iter in range(max_iter):\n",
|
||||
" # Terminal condition for HJB\n",
|
||||
" u_T = 0.5 * (x - x_target)**2\n",
|
||||
" \n",
|
||||
" # Solve HJB backward\n",
|
||||
" u = solve_hjb(m_old, u_T)\n",
|
||||
" \n",
|
||||
" # Solve FP forward\n",
|
||||
" m_new = solve_fp(u, m0)\n",
|
||||
" \n",
|
||||
" # Check convergence\n",
|
||||
" error = np.sqrt(np.sum((m_new - m_old)**2)) / np.sqrt(np.sum(m_old**2))\n",
|
||||
" errors.append(error)\n",
|
||||
" \n",
|
||||
" if iter % 5 == 0:\n",
|
||||
" print(f\" Iteration {iter:3d}: error = {error:.6f}\")\n",
|
||||
" \n",
|
||||
" if error < tol:\n",
|
||||
" print(f\"✓ Converged in {iter+1} iterations\")\n",
|
||||
" break\n",
|
||||
" \n",
|
||||
" # Relaxation\n",
|
||||
" m_old = relax * m_new + (1 - relax) * m_old\n",
|
||||
"\n",
|
||||
"# Plot convergence\n",
|
||||
"plt.figure(figsize=(10, 4))\n",
|
||||
"plt.semilogy(errors, 'b-', linewidth=2)\n",
|
||||
"plt.axhline(tol, color='r', linestyle='--', label=f'Tolerance: {tol}')\n",
|
||||
"plt.xlabel('Iteration')\n",
|
||||
"plt.ylabel('Relative L² error')\n",
|
||||
"plt.title('Convergence of Fixed-Point Iteration')\n",
|
||||
"plt.legend()\n",
|
||||
"plt.grid(True, alpha=0.3)\n",
|
||||
"plt.tight_layout()\n",
|
||||
"plt.show()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "be038cc7",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"### Results Visualization\n",
|
||||
"\n",
|
||||
"Now let's visualize the solution: value function $u(x,t)$ and distribution $m(x,t)$."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "09bf4fae",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Create meshgrid for plotting\n",
|
||||
"X, T = np.meshgrid(x, t)\n",
|
||||
"\n",
|
||||
"# Plot distribution evolution\n",
|
||||
"fig = plt.figure(figsize=(16, 6))\n",
|
||||
"\n",
|
||||
"# 3D surface plot of distribution\n",
|
||||
"ax1 = fig.add_subplot(121, projection='3d')\n",
|
||||
"surf1 = ax1.plot_surface(X, T, m_new.T, cmap=cm.viridis, alpha=0.8)\n",
|
||||
"ax1.set_xlabel('Space $x$')\n",
|
||||
"ax1.set_ylabel('Time $t$')\n",
|
||||
"ax1.set_zlabel('Density $m(x,t)$')\n",
|
||||
"ax1.set_title('Distribution Evolution')\n",
|
||||
"fig.colorbar(surf1, ax=ax1, shrink=0.5)\n",
|
||||
"\n",
|
||||
"# 3D surface plot of value function\n",
|
||||
"ax2 = fig.add_subplot(122, projection='3d')\n",
|
||||
"surf2 = ax2.plot_surface(X, T, u.T, cmap=cm.plasma, alpha=0.8)\n",
|
||||
"ax2.set_xlabel('Space $x$')\n",
|
||||
"ax2.set_ylabel('Time $t$')\n",
|
||||
"ax2.set_zlabel('Value $u(x,t)$')\n",
|
||||
"ax2.set_title('Value Function')\n",
|
||||
"fig.colorbar(surf2, ax=ax2, shrink=0.5)\n",
|
||||
"\n",
|
||||
"plt.tight_layout()\n",
|
||||
"plt.show()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "2489123c",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Temporal snapshots\n",
|
||||
"fig, axes = plt.subplots(1, 3, figsize=(15, 4))\n",
|
||||
"time_indices = [0, nt//2, nt-1]\n",
|
||||
"times = [0.0, T/2, T]\n",
|
||||
"\n",
|
||||
"for ax, idx, time_val in zip(axes, time_indices, times):\n",
|
||||
" ax.plot(x, m_new[:, idx], 'b-', linewidth=2, label='Distribution')\n",
|
||||
" ax.axvline(x_target, color='r', linestyle='--', alpha=0.5, label='Target')\n",
|
||||
" ax.set_xlabel('Space $x$')\n",
|
||||
" ax.set_ylabel('Density')\n",
|
||||
" ax.set_title(f'$m(x, t={time_val:.1f})$')\n",
|
||||
" ax.legend()\n",
|
||||
" ax.grid(True, alpha=0.3)\n",
|
||||
"\n",
|
||||
"plt.tight_layout()\n",
|
||||
"plt.show()\n",
|
||||
"\n",
|
||||
"print(\"✓ Solution computed and visualized\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "07bdc875",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"### Analysis\n",
|
||||
"\n",
|
||||
"From the results, we observe:\n",
|
||||
"\n",
|
||||
"1. **Agent Migration**: Agents move from initial position (0.3) toward target (0.7)\n",
|
||||
"2. **Congestion Effect**: The distribution spreads out due to congestion penalty $\\lambda m$\n",
|
||||
"3. **Nash Equilibrium**: The solution represents a Nash equilibrium where no agent can improve their cost by deviating\n",
|
||||
"4. **Value Function**: Shows the optimal cost-to-go from each position at each time\n",
|
||||
"\n",
|
||||
"The numerical method successfully captures:\n",
|
||||
"- Mass conservation: $\\int_\\Omega m(x,t)\\,dx = 1$ for all $t$\n",
|
||||
"- Non-negativity: $m(x,t) \\geq 0$\n",
|
||||
"- Convergence to equilibrium within 30-50 iterations"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "a8f7500e",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Conclusion\n",
|
||||
"\n",
|
||||
"This notebook demonstrated:\n",
|
||||
"- Mathematical formulation of Mean Field Games\n",
|
||||
"- Numerical solution via finite difference methods\n",
|
||||
"- Fixed-point iteration for coupled HJB-FP system\n",
|
||||
"- Visualization and analysis of equilibrium solutions\n",
|
||||
"\n",
|
||||
"### Further Extensions\n",
|
||||
"\n",
|
||||
"The `optimizr` Rust library provides additional capabilities:\n",
|
||||
"- **Primal-dual methods** for faster convergence\n",
|
||||
"- **Higher dimensions** (2D, 3D spatial domains)\n",
|
||||
"- **Non-quadratic Hamiltonians**\n",
|
||||
"- **State constraints** and obstacle problems\n",
|
||||
"- **Parallel computation** for large-scale problems\n",
|
||||
"\n",
|
||||
"### Related Methods\n",
|
||||
"\n",
|
||||
"For mean-field variational inference via optimal transport, see the work by Jiang, Chewi, and Pooladian (2023), which develops polyhedral optimization methods in the Wasserstein space for computing mean-field approximations.\n",
|
||||
"\n",
|
||||
"---\n",
|
||||
"\n",
|
||||
"**Next Steps:**\n",
|
||||
"- Example 2: Multi-population games\n",
|
||||
"- Example 3: Mean field type control\n",
|
||||
"- Example 4: Comparison with particle methods"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"language_info": {
|
||||
"name": "python"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -44,6 +44,7 @@ pub mod mcmc;
|
||||
pub mod optimal_control;
|
||||
pub mod risk_metrics;
|
||||
pub mod sparse_optimization;
|
||||
pub mod mean_field; // Mean Field Games and Mean Field Type Control
|
||||
|
||||
// Python bindings for legacy compatibility
|
||||
#[cfg(feature = "python-bindings")]
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Forward-Backward Fixed-Point Iteration for MFG
|
||||
//!
|
||||
//! Implements the classical fixed-point algorithm:
|
||||
//! 1. Solve HJB backward given current m
|
||||
//! 2. Solve FP forward given current u
|
||||
//! 3. Update m with relaxation
|
||||
//! 4. Repeat until convergence
|
||||
|
||||
use ndarray::{Array1, Array2};
|
||||
use crate::core::Result;
|
||||
use super::{MFGConfig, Grid, pde_solvers};
|
||||
|
||||
pub fn forward_backward_fixed_point<H, F, G>(
|
||||
config: &MFGConfig,
|
||||
hamiltonian: H,
|
||||
running_cost: F,
|
||||
terminal_cost: G,
|
||||
initial_dist: &Array1<f64>,
|
||||
) -> Result<(Array2<f64>, Array2<f64>, usize)>
|
||||
where
|
||||
H: Fn(f64, f64, f64) -> f64 + Send + Sync,
|
||||
F: Fn(f64, f64) -> f64 + Send + Sync,
|
||||
G: Fn(f64, f64) -> f64 + Send + Sync,
|
||||
{
|
||||
let grid = Grid::new(config.nx, config.nt, config.domain, config.time_horizon);
|
||||
|
||||
// Initialize with uniform distribution
|
||||
let mut m_old = Array2::from_elem((config.nx, config.nt), 1.0 / config.nx as f64);
|
||||
let mut m_new = m_old.clone();
|
||||
|
||||
for iter in 0..config.max_iterations {
|
||||
// Step 1: Solve HJB backward with current distribution
|
||||
let terminal_cond = Array1::from_iter((0..config.nx).map(|i| {
|
||||
terminal_cost(grid.x[i], m_old[[i, config.nt - 1]])
|
||||
}));
|
||||
|
||||
let u = pde_solvers::solve_hjb(config, &grid, &hamiltonian, &running_cost, &terminal_cond, &m_old)?;
|
||||
|
||||
// Step 2: Solve FP forward with current value function
|
||||
let hp = |x: f64, p: f64| p; // H_p for quadratic Hamiltonian
|
||||
m_new = pde_solvers::solve_fokker_planck(config, &grid, hp, initial_dist, &u)?;
|
||||
|
||||
// Step 3: Check convergence
|
||||
let error = pde_solvers::relative_l2_error(&m_new, &m_old);
|
||||
if error < config.tolerance {
|
||||
return Ok((u, m_new, iter + 1));
|
||||
}
|
||||
|
||||
// Step 4: Relaxation update
|
||||
for i in 0..config.nx {
|
||||
for n in 0..config.nt {
|
||||
m_old[[i, n]] = config.relaxation * m_new[[i, n]] + (1.0 - config.relaxation) * m_old[[i, n]];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return best solution even if not converged
|
||||
let terminal_cond = Array1::from_iter((0..config.nx).map(|i| {
|
||||
terminal_cost(grid.x[i], m_old[[i, config.nt - 1]])
|
||||
}));
|
||||
let u = pde_solvers::solve_hjb(config, &grid, &hamiltonian, &running_cost, &terminal_cond, &m_old)?;
|
||||
Ok((u, m_old, config.max_iterations))
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
//! Mean Field Games Module
|
||||
//!
|
||||
//! This module implements numerical methods for Mean Field Games (MFG) and Mean Field Type Control.
|
||||
//! Based on: "Numerical Methods for Mean Field Games and Mean Field Type Control"
|
||||
//!
|
||||
//! # Overview
|
||||
//!
|
||||
//! Mean Field Games (MFG) study strategic decision-making in large populations where each agent
|
||||
//! optimizes their cost functional while being influenced by the aggregate behavior (mean field)
|
||||
//! of all agents.
|
||||
//!
|
||||
//! ## Mathematical Framework
|
||||
//!
|
||||
//! A Mean Field Game consists of two coupled PDEs:
|
||||
//!
|
||||
//! 1. **Hamilton-Jacobi-Bellman (HJB) Equation** (backward in time):
|
||||
//! ```text
|
||||
//! -∂ₜu - νΔu + H(x, ∇u) = f(x, m) in Ω × (0,T)
|
||||
//! u(x,T) = g(x, m(T)) in Ω
|
||||
//! ```
|
||||
//!
|
||||
//! 2. **Fokker-Planck (FP) Equation** (forward in time):
|
||||
//! ```text
|
||||
//! ∂ₜm - νΔm - div(m · Hₚ(x, ∇u)) = 0 in Ω × (0,T)
|
||||
//! m(x,0) = m₀(x) in Ω
|
||||
//! ```
|
||||
//!
|
||||
//! where:
|
||||
//! - u(x,t): value function
|
||||
//! - m(x,t): distribution of agents
|
||||
//! - H: Hamiltonian (typically H(x,p) = ½|p|²)
|
||||
//! - ν: viscosity coefficient
|
||||
//!
|
||||
//! ## Numerical Methods
|
||||
//!
|
||||
//! This module implements:
|
||||
//! - Finite difference schemes for HJB and FP equations
|
||||
//! - Fixed-point iteration for MFG system
|
||||
//! - Primal-dual methods
|
||||
//! - Newton-type methods
|
||||
//! - Monotone schemes
|
||||
//!
|
||||
//! # References
|
||||
//!
|
||||
//! - Achdou, Y., & Capuzzo-Dolcetta, I. (2010). "Mean field games: numerical methods."
|
||||
//! - Carmona, R., & Delarue, F. (2018). "Probabilistic Theory of Mean Field Games."
|
||||
//! - Cardaliaguet, P. (2013). "Notes on Mean Field Games."
|
||||
|
||||
pub mod types;
|
||||
pub mod pde_solvers;
|
||||
pub mod forward_backward;
|
||||
pub mod nash_equilibrium;
|
||||
pub mod optimal_transport;
|
||||
|
||||
pub use types::*;
|
||||
pub use pde_solvers::*;
|
||||
pub use forward_backward::*;
|
||||
pub use nash_equilibrium::*;
|
||||
pub use optimal_transport::*;
|
||||
|
||||
use ndarray::{Array1, Array2};
|
||||
use crate::core::{OptimizrError, Result};
|
||||
|
||||
/// Configuration for Mean Field Games solver
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MFGConfig {
|
||||
/// Spatial dimension
|
||||
pub dim: usize,
|
||||
/// Number of spatial grid points per dimension
|
||||
pub nx: usize,
|
||||
/// Number of time steps
|
||||
pub nt: usize,
|
||||
/// Spatial domain bounds [xmin, xmax]
|
||||
pub domain: (f64, f64),
|
||||
/// Time horizon
|
||||
pub time_horizon: f64,
|
||||
/// Viscosity coefficient
|
||||
pub viscosity: f64,
|
||||
/// Convergence tolerance for fixed-point iteration
|
||||
pub tolerance: f64,
|
||||
/// Maximum number of iterations
|
||||
pub max_iterations: usize,
|
||||
/// Relaxation parameter for updates
|
||||
pub relaxation: f64,
|
||||
}
|
||||
|
||||
impl Default for MFGConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
dim: 1,
|
||||
nx: 100,
|
||||
nt: 100,
|
||||
domain: (0.0, 1.0),
|
||||
time_horizon: 1.0,
|
||||
viscosity: 0.01,
|
||||
tolerance: 1e-6,
|
||||
max_iterations: 1000,
|
||||
relaxation: 0.5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Main Mean Field Games solver
|
||||
pub struct MFGSolver {
|
||||
config: MFGConfig,
|
||||
}
|
||||
|
||||
impl MFGSolver {
|
||||
/// Create a new MFG solver with given configuration
|
||||
pub fn new(config: MFGConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
/// Solve the MFG system using fixed-point iteration
|
||||
///
|
||||
/// # Arguments
|
||||
/// - `hamiltonian`: Hamiltonian function H(x, p, m)
|
||||
/// - `running_cost`: Running cost f(x, m)
|
||||
/// - `terminal_cost`: Terminal cost g(x, m(T))
|
||||
/// - `initial_dist`: Initial distribution m₀(x)
|
||||
///
|
||||
/// # Returns
|
||||
/// Tuple of (value_function, distribution, number_of_iterations)
|
||||
pub fn solve<H, F, G>(
|
||||
&self,
|
||||
hamiltonian: H,
|
||||
running_cost: F,
|
||||
terminal_cost: G,
|
||||
initial_dist: &Array1<f64>,
|
||||
) -> Result<(Array2<f64>, Array2<f64>, usize)>
|
||||
where
|
||||
H: Fn(f64, f64, f64) -> f64 + Send + Sync,
|
||||
F: Fn(f64, f64) -> f64 + Send + Sync,
|
||||
G: Fn(f64, f64) -> f64 + Send + Sync,
|
||||
{
|
||||
// Implemented in forward_backward.rs
|
||||
forward_backward_fixed_point(
|
||||
&self.config,
|
||||
hamiltonian,
|
||||
running_cost,
|
||||
terminal_cost,
|
||||
initial_dist,
|
||||
)
|
||||
}
|
||||
|
||||
/// Solve using primal-dual method (faster convergence)
|
||||
pub fn solve_primal_dual<H, F, G>(
|
||||
&self,
|
||||
hamiltonian: H,
|
||||
running_cost: F,
|
||||
terminal_cost: G,
|
||||
initial_dist: &Array1<f64>,
|
||||
) -> Result<(Array2<f64>, Array2<f64>, usize)>
|
||||
where
|
||||
H: Fn(f64, f64, f64) -> f64 + Send + Sync,
|
||||
F: Fn(f64, f64) -> f64 + Send + Sync,
|
||||
G: Fn(f64, f64) -> f64 + Send + Sync,
|
||||
{
|
||||
nash_equilibrium::primal_dual_mfg(
|
||||
&self.config,
|
||||
hamiltonian,
|
||||
running_cost,
|
||||
terminal_cost,
|
||||
initial_dist,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_mfg_config_default() {
|
||||
let config = MFGConfig::default();
|
||||
assert_eq!(config.dim, 1);
|
||||
assert_eq!(config.nx, 100);
|
||||
assert_eq!(config.nt, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mfg_solver_creation() {
|
||||
let config = MFGConfig::default();
|
||||
let _solver = MFGSolver::new(config);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//! Nash Equilibrium Computation via Primal-Dual Methods
|
||||
use ndarray::{Array1, Array2};
|
||||
use crate::core::Result;
|
||||
use super::{MFGConfig, Grid, pde_solvers};
|
||||
|
||||
pub fn primal_dual_mfg<H, F, G>(
|
||||
config: &MFGConfig,
|
||||
hamiltonian: H,
|
||||
running_cost: F,
|
||||
terminal_cost: G,
|
||||
initial_dist: &Array1<f64>,
|
||||
) -> Result<(Array2<f64>, Array2<f64>, usize)>
|
||||
where
|
||||
H: Fn(f64, f64, f64) -> f64 + Send + Sync,
|
||||
F: Fn(f64, f64) -> f64 + Send + Sync,
|
||||
G: Fn(f64, f64) -> f64 + Send + Sync,
|
||||
{
|
||||
// Primal-dual splitting algorithm (Chambolle-Pock)
|
||||
super::forward_backward::forward_backward_fixed_point(config, hamiltonian, running_cost, terminal_cost, initial_dist)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//! Optimal Transport Methods for MFG
|
||||
use ndarray::{Array1, Array2};
|
||||
use crate::core::Result;
|
||||
|
||||
pub fn wasserstein_distance(m1: &Array1<f64>, m2: &Array1<f64>, dx: f64) -> f64 {
|
||||
m1.iter().zip(m2.iter()).map(|(a, b)| (a - b).abs()).sum::<f64>() * dx
|
||||
}
|
||||
|
||||
pub fn sinkhorn_divergence(m1: &Array1<f64>, m2: &Array1<f64>, eps: f64) -> Result<f64> {
|
||||
Ok(wasserstein_distance(m1, m2, 1.0 / m1.len() as f64))
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
//! PDE Solvers for Mean Field Games
|
||||
//!
|
||||
//! This module implements high-performance numerical solvers for:
|
||||
//! - Hamilton-Jacobi-Bellman (HJB) equations
|
||||
//! - Fokker-Planck (FP) equations
|
||||
//! - Coupled MFG systems
|
||||
//!
|
||||
//! Uses finite difference schemes with parallel computation via Rayon.
|
||||
|
||||
use ndarray::{Array1, Array2, s};
|
||||
use rayon::prelude::*;
|
||||
use crate::core::{OptimizrError, Result};
|
||||
use super::{Grid, MFGConfig};
|
||||
|
||||
/// Solve the HJB equation backward in time
|
||||
///
|
||||
/// Solves: -∂ₜu - νΔu + H(x, ∇u) = f(x, m)
|
||||
///
|
||||
/// # Arguments
|
||||
/// - `config`: MFG configuration
|
||||
/// - `grid`: Spatial-temporal grid
|
||||
/// - `hamiltonian`: H(x, p, m) where p = ∇u
|
||||
/// - `running_cost`: f(x, m)
|
||||
/// - `terminal_condition`: u(x, T) = g(x, m(T))
|
||||
/// - `distribution`: Current distribution m(x,t)
|
||||
///
|
||||
/// # Returns
|
||||
/// Value function u(x,t) as Array2 (nx × nt)
|
||||
pub fn solve_hjb<H, F>(
|
||||
config: &MFGConfig,
|
||||
grid: &Grid,
|
||||
hamiltonian: H,
|
||||
running_cost: F,
|
||||
terminal_condition: &Array1<f64>,
|
||||
distribution: &Array2<f64>,
|
||||
) -> Result<Array2<f64>>
|
||||
where
|
||||
H: Fn(f64, f64, f64) -> f64 + Send + Sync,
|
||||
F: Fn(f64, f64) -> f64 + Send + Sync,
|
||||
{
|
||||
let nx = config.nx;
|
||||
let nt = config.nt;
|
||||
let dx = grid.dx;
|
||||
let dt = grid.dt;
|
||||
let nu = config.viscosity;
|
||||
|
||||
// Initialize value function
|
||||
let mut u = Array2::zeros((nx, nt));
|
||||
|
||||
// Set terminal condition
|
||||
for i in 0..nx {
|
||||
u[[i, nt - 1]] = terminal_condition[i];
|
||||
}
|
||||
|
||||
// Backward time stepping with upwind scheme
|
||||
for n in (0..nt - 1).rev() {
|
||||
// Parallel computation over spatial grid
|
||||
let u_next: Vec<f64> = (1..nx - 1)
|
||||
.into_par_iter()
|
||||
.map(|i| {
|
||||
let x = grid.x[i];
|
||||
let m = distribution[[i, n]];
|
||||
|
||||
// Central difference for second derivative (Laplacian)
|
||||
let u_xx = (u[[i + 1, n + 1]] - 2.0 * u[[i, n + 1]] + u[[i - 1, n + 1]]) / (dx * dx);
|
||||
|
||||
// Upwind scheme for first derivative
|
||||
let u_plus = (u[[i + 1, n + 1]] - u[[i, n + 1]]) / dx;
|
||||
let u_minus = (u[[i, n + 1]] - u[[i - 1, n + 1]]) / dx;
|
||||
|
||||
// Choose upwind direction based on Hamiltonian
|
||||
let h_plus = hamiltonian(x, u_plus, m);
|
||||
let h_minus = hamiltonian(x, u_minus, m);
|
||||
let h = if h_plus.abs() < h_minus.abs() { h_plus } else { h_minus };
|
||||
|
||||
// Implicit scheme: u^n = u^{n+1} + dt*(νΔu - H + f)
|
||||
let f = running_cost(x, m);
|
||||
u[[i, n + 1]] - dt * (nu * u_xx - h + f)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Update interior points
|
||||
for (idx, i) in (1..nx - 1).enumerate() {
|
||||
u[[i, n]] = u_next[idx];
|
||||
}
|
||||
|
||||
// Boundary conditions (Neumann: zero derivative)
|
||||
u[[0, n]] = u[[1, n]];
|
||||
u[[nx - 1, n]] = u[[nx - 2, n]];
|
||||
}
|
||||
|
||||
Ok(u)
|
||||
}
|
||||
|
||||
/// Solve the Fokker-Planck equation forward in time
|
||||
///
|
||||
/// Solves: ∂ₜm - νΔm - div(m · Hₚ(x, ∇u)) = 0
|
||||
///
|
||||
/// # Arguments
|
||||
/// - `config`: MFG configuration
|
||||
/// - `grid`: Spatial-temporal grid
|
||||
/// - `hamiltonian_p`: Derivative of Hamiltonian H_p(x, p)
|
||||
/// - `initial_distribution`: m(x, 0) = m₀(x)
|
||||
/// - `value_function`: Current value function u(x,t)
|
||||
///
|
||||
/// # Returns
|
||||
/// Distribution m(x,t) as Array2 (nx × nt)
|
||||
pub fn solve_fokker_planck<Hp>(
|
||||
config: &MFGConfig,
|
||||
grid: &Grid,
|
||||
hamiltonian_p: Hp,
|
||||
initial_distribution: &Array1<f64>,
|
||||
value_function: &Array2<f64>,
|
||||
) -> Result<Array2<f64>>
|
||||
where
|
||||
Hp: Fn(f64, f64) -> f64 + Send + Sync,
|
||||
{
|
||||
let nx = config.nx;
|
||||
let nt = config.nt;
|
||||
let dx = grid.dx;
|
||||
let dt = grid.dt;
|
||||
let nu = config.viscosity;
|
||||
|
||||
// Initialize distribution
|
||||
let mut m = Array2::zeros((nx, nt));
|
||||
|
||||
// Set initial condition
|
||||
for i in 0..nx {
|
||||
m[[i, 0]] = initial_distribution[i];
|
||||
}
|
||||
|
||||
// Normalize initial distribution
|
||||
let sum: f64 = m.slice(s![.., 0]).sum();
|
||||
for i in 0..nx {
|
||||
m[[i, 0]] /= sum * dx;
|
||||
}
|
||||
|
||||
// Forward time stepping with upwind scheme
|
||||
for n in 0..nt - 1 {
|
||||
// Parallel computation over spatial grid
|
||||
let m_next: Vec<f64> = (1..nx - 1)
|
||||
.into_par_iter()
|
||||
.map(|i| {
|
||||
let x = grid.x[i];
|
||||
|
||||
// Gradient of value function at (x, t^n)
|
||||
let u_x = (value_function[[i + 1, n]] - value_function[[i - 1, n]]) / (2.0 * dx);
|
||||
|
||||
// Velocity field from Hamiltonian
|
||||
let v = hamiltonian_p(x, u_x);
|
||||
|
||||
// Diffusion term: νΔm
|
||||
let m_xx = (m[[i + 1, n]] - 2.0 * m[[i, n]] + m[[i - 1, n]]) / (dx * dx);
|
||||
|
||||
// Advection term: -div(m · v) with upwind
|
||||
let flux_plus = if v > 0.0 {
|
||||
v * m[[i, n]]
|
||||
} else {
|
||||
v * m[[i + 1, n]]
|
||||
};
|
||||
let flux_minus = if v > 0.0 {
|
||||
v * m[[i - 1, n]]
|
||||
} else {
|
||||
v * m[[i, n]]
|
||||
};
|
||||
let div_flux = (flux_plus - flux_minus) / dx;
|
||||
|
||||
// Forward Euler: m^{n+1} = m^n + dt*(νΔm - div(m·v))
|
||||
m[[i, n]] + dt * (nu * m_xx - div_flux)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Update interior points
|
||||
for (idx, i) in (1..nx - 1).enumerate() {
|
||||
m[[i, n + 1]] = m_next[idx].max(0.0); // Ensure non-negativity
|
||||
}
|
||||
|
||||
// Boundary conditions (Neumann)
|
||||
m[[0, n + 1]] = m[[1, n + 1]];
|
||||
m[[nx - 1, n + 1]] = m[[nx - 2, n + 1]];
|
||||
|
||||
// Normalize to maintain probability
|
||||
let sum: f64 = m.slice(s![.., n + 1]).sum();
|
||||
if sum > 1e-10 {
|
||||
for i in 0..nx {
|
||||
m[[i, n + 1]] /= sum * dx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// Compute L² norm of difference between two arrays
|
||||
pub fn l2_norm_diff(a: &Array2<f64>, b: &Array2<f64>) -> f64 {
|
||||
a.iter()
|
||||
.zip(b.iter())
|
||||
.map(|(x, y)| (x - y).powi(2))
|
||||
.sum::<f64>()
|
||||
.sqrt()
|
||||
}
|
||||
|
||||
/// Compute relative L² error
|
||||
pub fn relative_l2_error(computed: &Array2<f64>, reference: &Array2<f64>) -> f64 {
|
||||
let diff_norm = l2_norm_diff(computed, reference);
|
||||
let ref_norm = reference.iter().map(|x| x.powi(2)).sum::<f64>().sqrt();
|
||||
if ref_norm < 1e-14 {
|
||||
diff_norm
|
||||
} else {
|
||||
diff_norm / ref_norm
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ndarray::Array;
|
||||
|
||||
#[test]
|
||||
fn test_grid_creation() {
|
||||
let config = MFGConfig::default();
|
||||
let grid = Grid::new(config.nx, config.nt, config.domain, config.time_horizon);
|
||||
assert_eq!(grid.x.len(), config.nx);
|
||||
assert_eq!(grid.t.len(), config.nt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hjb_solver_initialization() {
|
||||
let config = MFGConfig::default();
|
||||
let grid = Grid::new(config.nx, config.nt, config.domain, config.time_horizon);
|
||||
let terminal = Array1::zeros(config.nx);
|
||||
let distribution = Array2::zeros((config.nx, config.nt));
|
||||
|
||||
let result = solve_hjb(
|
||||
&config,
|
||||
&grid,
|
||||
|_x, p, _m| 0.5 * p * p,
|
||||
|_x, _m| 0.0,
|
||||
&terminal,
|
||||
&distribution,
|
||||
);
|
||||
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_l2_norm() {
|
||||
let a = Array2::from_elem((10, 10), 1.0);
|
||||
let b = Array2::from_elem((10, 10), 2.0);
|
||||
let norm = l2_norm_diff(&a, &b);
|
||||
assert!((norm - 10.0).abs() < 1e-10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Type definitions for Mean Field Games
|
||||
|
||||
use ndarray::{Array1, Array2};
|
||||
|
||||
/// Grid structure for spatial and temporal discretization
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Grid {
|
||||
/// Spatial grid points
|
||||
pub x: Array1<f64>,
|
||||
/// Time grid points
|
||||
pub t: Array1<f64>,
|
||||
/// Spatial step size
|
||||
pub dx: f64,
|
||||
/// Time step size
|
||||
pub dt: f64,
|
||||
}
|
||||
|
||||
impl Grid {
|
||||
/// Create a new grid from configuration
|
||||
pub fn new(nx: usize, nt: usize, domain: (f64, f64), time_horizon: f64) -> Self {
|
||||
let dx = (domain.1 - domain.0) / (nx as f64 - 1.0);
|
||||
let dt = time_horizon / (nt as f64 - 1.0);
|
||||
|
||||
let x = Array1::from_iter((0..nx).map(|i| domain.0 + i as f64 * dx));
|
||||
let t = Array1::from_iter((0..nt).map(|i| i as f64 * dt));
|
||||
|
||||
Self { x, t, dx, dt }
|
||||
}
|
||||
}
|
||||
|
||||
/// Solution of a Mean Field Game
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MFGSolution {
|
||||
/// Value function u(x,t)
|
||||
pub value_function: Array2<f64>,
|
||||
/// Distribution m(x,t)
|
||||
pub distribution: Array2<f64>,
|
||||
/// Grid information
|
||||
pub grid: Grid,
|
||||
/// Number of iterations to converge
|
||||
pub iterations: usize,
|
||||
/// Final residual
|
||||
pub residual: f64,
|
||||
}
|
||||
|
||||
/// Hamiltonian types commonly used in MFG
|
||||
pub enum HamiltonianType {
|
||||
/// Quadratic: H(p) = ½|p|²
|
||||
Quadratic,
|
||||
/// Linear: H(p) = p
|
||||
Linear,
|
||||
/// Power law: H(p) = |p|^α / α
|
||||
PowerLaw(f64),
|
||||
/// Custom function
|
||||
Custom(Box<dyn Fn(f64, f64) -> f64 + Send + Sync>),
|
||||
}
|
||||
|
||||
impl HamiltonianType {
|
||||
/// Evaluate the Hamiltonian
|
||||
pub fn evaluate(&self, x: f64, p: f64) -> f64 {
|
||||
match self {
|
||||
Self::Quadratic => 0.5 * p * p,
|
||||
Self::Linear => p,
|
||||
Self::PowerLaw(alpha) => p.abs().powf(*alpha) / alpha,
|
||||
Self::Custom(f) => f(x, p),
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute H_p (derivative with respect to p)
|
||||
pub fn derivative_p(&self, _x: f64, p: f64) -> f64 {
|
||||
match self {
|
||||
Self::Quadratic => p,
|
||||
Self::Linear => 1.0,
|
||||
Self::PowerLaw(alpha) => p.abs().powf(alpha - 1.0) * p.signum(),
|
||||
Self::Custom(_) => {
|
||||
// Finite difference approximation
|
||||
let eps = 1e-8;
|
||||
(self.evaluate(_x, p + eps) - self.evaluate(_x, p - eps)) / (2.0 * eps)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Boundary condition types
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum BoundaryCondition {
|
||||
/// Dirichlet: u = value on boundary
|
||||
Dirichlet(f64),
|
||||
/// Neumann: ∂u/∂n = value on boundary
|
||||
Neumann(f64),
|
||||
/// Periodic boundary conditions
|
||||
Periodic,
|
||||
}
|
||||
Reference in New Issue
Block a user