建模指南#
这一页真正要回答的问题,不是“API 里有哪些名字”,而是“经济学里的哪一部分应该放到哪个 FinHJB 对象里”。
直接使用 package 时,请在 库快速上手 之后阅读;从 BCW 模板走向自己的模型时,请在 BCW2011 案例总览 之后阅读。如果你现在只想查精确导出名和签名,请改看 API 参考。
四个核心组件#
每一个 FinHJB 模型,本质上都由四部分组成:
AbstractParameter:不可变的经济参数和数值参数;AbstractBoundary:状态边界和值边界;AbstractPolicy:控制变量如何初始化、如何更新;AbstractModel:HJB 残差方程,以及可选的边界辅助逻辑。
一个很好记的理解方式是:
Parameter说“世界是什么样的”,Boundary说“世界从哪里开始,到哪里结束”,Policy说“主体怎么决策”,Model说“需要满足哪一个 HJB 方程”。
AbstractParameter:不可变输入#
把所有应当被视为“模型设定”的量都放进 AbstractParameter:
利率,
折旧率,
波动率,
调整成本参数,
融资摩擦参数,
以及任何你希望在 continuation 里逐步变化的标量参数。
例子:
class Parameter(fjb.AbstractParameter):
r: float = 0.03
sigma: float = 0.15
好的做法:
字段尽量保持数值型、不可变;
命名要有经济含义;
派生量可以用
cached_property;如果边界变化会影响派生参数,可重写
update(boundary)。
常见错误:
把可变容器塞进参数对象;
把重要经济常数偷偷写在
Policy或Model里;明明边界变化会影响参数,却忘了使用
update(boundary)。
AbstractBoundary:统一管理状态和值边界#
边界对象要管理四个量:
s_mins_maxv_leftv_right
这些边界有两种定义方式:
直接在构造函数里显式给定;
通过
compute_<boundary_name>方法间接计算。
例如:
@dataclass
class Boundary(fjb.AbstractBoundary[Parameter]):
@staticmethod
def compute_v_left(p: Parameter) -> float:
return 0.0
@staticmethod
def compute_v_right(p: Parameter, s_max: float) -> float:
return 1.0 + 0.1 * s_max
FinHJB 会自动根据方法签名推断依赖关系:
方法名决定“你在算哪个边界”,
参数名决定“它依赖哪些已有边界”,
p被识别为参数对象,而不是另外一个边界。
因此 compute_v_right(p, s_max) 的含义就是:
“要算 v_right,我需要参数对象 p 和状态上边界 s_max。”
关于边界,有几个规则必须记住#
同一个边界不能既显式赋值,又定义
compute_*方法;必须满足
s_min < s_max;循环依赖会被拒绝;
缺失依赖会在很早阶段就报错。
什么情况下要用 boundary_condition()#
当某个边界值不是事先已知的,而是要让“解出来的网格满足某个条件”时,就要在 Model.boundary_condition() 里定义目标。
BCW liquidation 的典型例子就是:
def s_max_condition(grid) -> float:
return grid.d2v[-1]
含义是:搜索一个边界,使得右端曲率趋于零。
在实际接口里,boundary_condition() 返回的是一个 BoundaryConditionTarget 列表。这个列表不只是“把条件写出来”这么简单,它还决定了:
只有出现在列表里的边界,才会进入
boundary_search();多边界搜索时,列表顺序就是边界参数向量顺序;
对
method="bisection"而言,这个顺序还会变成嵌套搜索的外层到内层顺序;如果要用
bisection,每个 target 都必须给low和high;tol和max_iter这两个字段也主要是给bisection用的。
如果你用的是其他 boundary-search 方法,则主要使用 Config.bs_tol 和 Config.bs_max_iter。
AbstractPolicyDict:声明控制变量#
AbstractPolicyDict 是一个类型化容器,用来声明策略数组有哪些键。
例如:
class PolicyDict(fjb.AbstractPolicyDict):
investment: Array
psi: Array
经验法则:
后续会从
grid.policy[...]中读取的变量,都应该写在这里。
如果一个变量会出现在 Model.hjb_residual 中,它通常就应该在 PolicyDict 里有一席之地。
AbstractPolicy:策略初始化与策略更新#
策略类主要负责两件事:
提供初始猜测;
在迭代中更新控制变量。
initialize(grid, p)#
这是必须实现的方法,而且必须返回一个完整的 PolicyDict。
你需要检查:
每个必需键都存在;
每个数组都和网格长度匹配;
初值至少在经济上和数值上说得过去。
@explicit_policy#
当策略更新可以直接写成闭式表达时,用 @explicit_policy 最自然。
例子:
@staticmethod
@fjb.explicit_policy(order=1)
def update_investment(grid: fjb.Grid) -> fjb.Grid:
grid.policy["investment"] = ...
return grid
适用场景:
一阶条件能直接化简成显式公式;
更新逻辑简单而稳定;
你希望代码路径最直接、最容易读。
@implicit_policy#
当策略更自然地写成残差方程或根问题时,用 @implicit_policy。
BCW liquidation 中投资策略就是这种形式:
@staticmethod
@fjb.implicit_policy(order=2, solver="lm", policy_order=["investment"])
def cal_investment_without_explicit(policy, v, dv, d2v, s, p):
investment = policy[0]
return jnp.array([(1 / p.theta) * (v / dv - s - 1) - investment])
适用场景:
你更容易把策略写成
FOC(...) = 0;需要非线性根求解器;
想让多个控制共享统一的残差式实现。
策略层的常见错误#
模型需要两个控制,但
initialize只返回了一个;policy_order与残差返回顺序不一致;@explicit_policy更新完后忘记返回grid;在公式里直接除以
dv或d2v,却没意识到这些量可能在某些状态下非常小。
AbstractModel:HJB 残差的主体#
最少必须实现的方法是:
hjb_residual(v, dv, d2v, s, policy, jump, boundary, p)
这个函数需要返回每个内部网格点上的残差,求解器的任务就是让它逼近零。
常见输入:
v,dv,d2v:当前价值函数及导数;s:状态网格;policy:当前控制变量;jump:跳跃项;boundary:冻结后的边界值;p:参数对象。
可选钩子包括:
jump(...):如果模型有非零跳跃项;boundary_condition():如果需要边界搜索;update_boundary(grid):如果需要外层边界更新;auxiliary(grid):如果想自定义额外诊断量。
什么时候需要重写 jump(...)#
大多数模型都不需要,默认实现就是零。
只有当你的 HJB 里真的存在额外的跳跃项时,才需要重写它。求解器是通过 Grid.jump_inter 来调用这个钩子的,所以实际上传进去的是内部网格切片,而不是包含两端边界点的整条数组。
boundary_condition() 应该返回什么#
返回值是一个 BoundaryConditionTarget(...) 列表。
每个 target 至少给出:
boundary_name:要搜索哪个边界字段,比如s_max或v_left;condition_func(grid):你想逼近零的残差;low/high:给bisection用的 bracket;tol/max_iter:给bisection用的单目标设置。
如果你使用的是 hybr、lm、broyden、gauss_newton、krylov、broyden1 或 lbfgs,则主要使用 Config.bs_tol 和 Config.bs_max_iter。
auxiliary(grid) 是干什么的#
auxiliary(grid) 就是 grid.aux 背后的钩子。
只有当你想返回 grid.df 和 grid.boundary 之外的额外派生诊断量时,才建议实现它。一个很稳妥的模式是返回字典:
@staticmethod
def auxiliary(grid: fjb.Grid):
return {"value_mean": jnp.mean(grid.v)}
如果你没有实现它,那么 grid.aux 抛出 NotImplementedError 是正常行为。
一个很稳妥的实现顺序#
自己搭新模型时,建议按下面顺序来:
先写
Parameter;再写
Boundary;再写
PolicyDict;实现
Policy.initialize;写
Model.hjb_residual的第一版;先让
solver.solve()在固定边界下跑起来;最后才加
boundary_condition()或update_boundary()。
这个顺序的好处是:每次只调一个层面。如果你一开始就把边界搜索也加上,往往会把“模型错”和“搜索错”混在一起。
最小模板#
class Parameter(fjb.AbstractParameter):
r: float = 0.03
class PolicyDict(fjb.AbstractPolicyDict):
investment: Array
@dataclass
class Boundary(fjb.AbstractBoundary[Parameter]):
@staticmethod
def compute_v_left(p: Parameter) -> float:
return 0.0
@staticmethod
def compute_v_right(p: Parameter, s_max: float) -> float:
return 1.0 + 0.1 * s_max
@dataclass
class Policy(fjb.AbstractPolicy[Parameter, PolicyDict]):
@staticmethod
def initialize(grid: fjb.Grid, p: Parameter) -> PolicyDict:
return PolicyDict(investment=jnp.full_like(grid.s, 0.1))
@dataclass
class Model(fjb.AbstractModel[Parameter, PolicyDict]):
@staticmethod
def hjb_residual(v, dv, d2v, s, policy, jump, boundary, p):
inv = policy["investment"]
return -p.r * v + (s - inv) * dv + 0.5 * p.sigma**2 * d2v
相关页面#
看 求解器指南:决定工作流。
看 把 BCW 改成你自己的模型:沿 BCW 主线做迁移。
看 API 参考:只查精确成员和签名。