建模指南#

这一页真正要回答的问题,不是“API 里有哪些名字”,而是“经济学里的哪一部分应该放到哪个 FinHJB 对象里”。

直接使用 package 时,请在 库快速上手 之后阅读;从 BCW 模板走向自己的模型时,请在 BCW2011 案例总览 之后阅读。如果你现在只想查精确导出名和签名,请改看 API 参考

四个核心组件#

每一个 FinHJB 模型,本质上都由四部分组成:

  1. AbstractParameter:不可变的经济参数和数值参数;

  2. AbstractBoundary:状态边界和值边界;

  3. AbstractPolicy:控制变量如何初始化、如何更新;

  4. 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)

常见错误:

  • 把可变容器塞进参数对象;

  • 把重要经济常数偷偷写在 PolicyModel 里;

  • 明明边界变化会影响参数,却忘了使用 update(boundary)

AbstractBoundary:统一管理状态和值边界#

边界对象要管理四个量:

  • s_min

  • s_max

  • v_left

  • v_right

这些边界有两种定义方式:

  1. 直接在构造函数里显式给定;

  2. 通过 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 都必须给 lowhigh

  • tolmax_iter 这两个字段也主要是给 bisection 用的。

如果你用的是其他 boundary-search 方法,则主要使用 Config.bs_tolConfig.bs_max_iter

AbstractPolicyDict:声明控制变量#

AbstractPolicyDict 是一个类型化容器,用来声明策略数组有哪些键。

例如:

class PolicyDict(fjb.AbstractPolicyDict):
    investment: Array
    psi: Array

经验法则:

  • 后续会从 grid.policy[...] 中读取的变量,都应该写在这里。

如果一个变量会出现在 Model.hjb_residual 中,它通常就应该在 PolicyDict 里有一席之地。

AbstractPolicy:策略初始化与策略更新#

策略类主要负责两件事:

  1. 提供初始猜测;

  2. 在迭代中更新控制变量。

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

  • 在公式里直接除以 dvd2v,却没意识到这些量可能在某些状态下非常小。

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_maxv_left

  • condition_func(grid):你想逼近零的残差;

  • low / high:给 bisection 用的 bracket;

  • tol / max_iter:给 bisection 用的单目标设置。

如果你使用的是 hybrlmbroydengauss_newtonkrylovbroyden1lbfgs,则主要使用 Config.bs_tolConfig.bs_max_iter

auxiliary(grid) 是干什么的#

auxiliary(grid) 就是 grid.aux 背后的钩子。

只有当你想返回 grid.dfgrid.boundary 之外的额外派生诊断量时,才建议实现它。一个很稳妥的模式是返回字典:

@staticmethod
def auxiliary(grid: fjb.Grid):
    return {"value_mean": jnp.mean(grid.v)}

如果你没有实现它,那么 grid.aux 抛出 NotImplementedError 是正常行为。

一个很稳妥的实现顺序#

自己搭新模型时,建议按下面顺序来:

  1. 先写 Parameter

  2. 再写 Boundary

  3. 再写 PolicyDict

  4. 实现 Policy.initialize

  5. Model.hjb_residual 的第一版;

  6. 先让 solver.solve() 在固定边界下跑起来;

  7. 最后才加 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

相关页面#