59. 工作搜寻 VII:职业选择建模#

GPU

本讲座是在配有GPU的机器上构建的——不过没有GPU也可以运行。

Google Colab 提供带GPU的免费套餐,使用方法如下:

  1. 点击右上角的”播放”图标

  2. 选择 Colab

  3. 将运行时环境设置为包含GPU

除了 Anaconda 中已有的库外,本讲座还需要以下库:

!pip install quantecon jax

Hide code cell output

Requirement already satisfied: quantecon in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (0.11.4)
Requirement already satisfied: jax in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (0.11.1)
Requirement already satisfied: numba>=0.49.0 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from quantecon) (0.65.1)
Requirement already satisfied: numpy>=1.17.0 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from quantecon) (2.4.6)
Requirement already satisfied: requests in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from quantecon) (2.34.2)
Requirement already satisfied: scipy>=1.5.0 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from quantecon) (1.18.0)
Requirement already satisfied: sympy in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from quantecon) (1.14.0)
Requirement already satisfied: jaxlib<=0.11.1,>=0.11.1 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from jax) (0.11.1)
Requirement already satisfied: ml_dtypes>=0.5.0 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from jax) (0.6.0)
Requirement already satisfied: opt_einsum in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from jax) (3.4.0)
Requirement already satisfied: llvmlite<0.48,>=0.47.0dev0 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from numba>=0.49.0->quantecon) (0.47.0)
Requirement already satisfied: charset_normalizer<4,>=2 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from requests->quantecon) (3.4.7)
Requirement already satisfied: idna<4,>=2.5 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from requests->quantecon) (3.18)
Requirement already satisfied: urllib3<3,>=1.26 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from requests->quantecon) (2.7.0)
Requirement already satisfied: certifi>=2023.5.7 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from requests->quantecon) (2026.6.17)
Requirement already satisfied: mpmath<1.4,>=1.1.0 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from sympy->quantecon) (1.3.0)

59.1. 概述#

接下来,我们研究一个关于职业和工作选择的计算问题。

这个模型最初由Derek Neal提出[Neal, 1999]

本文的讲解借鉴了[Ljungqvist and Sargent, 2018]第6.5节的内容。

我们先导入一些包:

from typing import NamedTuple

import matplotlib.pyplot as plt
import matplotlib as mpl
FONTPATH = "fonts/SourceHanSerifSC-SemiBold.otf"
mpl.font_manager.fontManager.addfont(FONTPATH)
plt.rcParams['font.family'] = ['Source Han Serif SC', 'DejaVu Sans']

from matplotlib import cm
from mpl_toolkits.mplot3d.axes3d import Axes3D
import jax
import jax.numpy as jnp
import jax.random as jr
from quantecon.distributions import BetaBinomial

59.1.1. 模型特点#

  • 模型中的个体们通过选择职业和职业内的工作来最大化预期的贴现工资收入。

  • 这是一个包含两个状态变量的无限期动态规划问题。

59.2. 模型#

在下文中,我们区分职业和工作,其中

  • 职业被理解为包含许多工作的一个领域,而

  • 工作被理解为在特定公司的一个职位

对于劳动者来说,工资可以分解为工作和职业的贡献

  • \(w_t = \theta_t + \epsilon_t\),其中

    • \(\theta_t\) 是在时间 \(t\) 职业的贡献

    • \(\epsilon_t\) 是在时间 \(t\) 工作的贡献

在时间 \(t\) 开始时,劳动者有以下选择

  • 保持当前的(职业,工作)组合 \((\theta_t, \epsilon_t)\) — 以下简称为”原地不动”

  • 保持当前职业 \(\theta_t\) 但重新选择工作 \(\epsilon_t\) — 以下简称为”新工作”

  • 同时重新选择职业 \(\theta_t\) 和工作 \(\epsilon_t\) — 以下简称”新生活”

\(\theta\)\(\epsilon\) 的抽取彼此独立,且与过去的值无关,其中:

  • \(\theta_t \sim F\)

  • \(\epsilon_t \sim G\)

注意,劳动者没有保留工作但重新选择职业的选项 — 开始新职业总是需要开始新工作。

年轻劳动者的目标是最大化折现工资的预期总和

(59.1)#\[\mathbb{E} \sum_{t=0}^{\infty} \beta^t w_t\]

且受限于上述的选择限制。

\(v(\theta, \epsilon)\) 表示价值函数,即在给定初始状态 \((\theta, \epsilon)\) 的情况下,所有可行的(职业,工作)策略中 (59.1) 的最大值。

价值函数满足

\[ v(\theta, \epsilon) = \max\{I, II, III\} \]

其中

(59.2)#\[\begin{split}\begin{aligned} & I = \theta + \epsilon + \beta v(\theta, \epsilon) \\ & II = \theta + \int \epsilon' G(d \epsilon') + \beta \int v(\theta, \epsilon') G(d \epsilon') \nonumber \\ & III = \int \theta' F(d \theta') + \int \epsilon' G(d \epsilon') + \beta \int \int v(\theta', \epsilon') G(d \epsilon') F(d \theta') \nonumber \end{aligned}\end{split}\]

显然 \(I\)\(II\)\(III\) 分别对应”原地不动”、“新工作”和”新生活”。

59.2.1. 参数化#

如同 [Ljungqvist and Sargent, 2018] 第6.5节所述,我们将关注模型的离散版本,参数设置如下:

  • \(\theta\)\(\epsilon\) 的取值都在集合 jnp.linspace(0, B, grid_size) 中 — 在 \(0\)\(B\) 之间(包含端点)的均匀网格点

  • grid_size = 50

  • B = 5

  • β = 0.95

分布 \(F\)\(G\) 是离散分布,从网格点 jnp.linspace(0, B, grid_size) 中生成抽样。

Beta-二项分布族是一个非常有用的离散分布族,其概率质量函数为

\[ p(k \,|\, n, a, b) = {n \choose k} \frac{B(k + a, n - k + b)}{B(a, b)}, \qquad k = 0, \ldots, n \]

解释:

  • 从形状参数为 \((a, b)\) 的 Beta 分布中抽取 \(q\)

  • 进行 \(n\) 次独立的二值试验,每次成功概率为 \(q\)

  • \(p(k \,|\, n, a, b)\) 就是这 \(n\) 次试验中出现 \(k\) 次成功的概率

优良性质:

  • 形式非常灵活的一类分布,包括均匀分布、对称单峰分布等

  • 只有三个参数

下图展示了当\(n=50\)时,不同形状参数对概率质量函数的影响。

n = 50
a_vals = [0.5, 1, 100]
b_vals = [0.5, 1, 100]

fig, ax = plt.subplots(figsize=(10, 6))
for a, b in zip(a_vals, b_vals):
    ab_label = f'$a = {a:.1f}$, $b = {b:.1f}$'
    ax.plot(range(n + 1), BetaBinomial(n, a, b).pdf(), '-o', label=ab_label)
ax.legend()
plt.show()
findfont: Failed to find font weight normal, now using 600.
findfont: Failed to find font weight normal, now using 600.
findfont: Failed to find font weight normal, now using 600.
_images/51d2d92bc12bbb1cc455a14e91b15293dd2f660eca9d3332aefca15f5ba33c76.png

59.3. 实现#

我们将模型的基本参数存储在一个 NamedTuple 中,该结构由一个工厂函数构建。

class CareerWorkerProblem(NamedTuple):
    β: float                 # 贴现因子
    θ: jnp.ndarray           # θ值集合(职业)
    ϵ: jnp.ndarray           # ϵ值集合(工作)
    F_probs: jnp.ndarray     # 新职业抽取的分布
    G_probs: jnp.ndarray     # 新工作抽取的分布
    F_mean: float            # F 的均值
    G_mean: float            # G 的均值


def create_career_worker_problem(B=5.0,          # 上界
                                 β=0.95,         # 贴现因子
                                 grid_size=50,   # 网格大小
                                 F_a=1,
                                 F_b=1,
                                 G_a=1,
                                 G_b=1):
    "创建职业选择模型的一个实例。"
    θ = jnp.linspace(0, B, grid_size)
    ϵ = jnp.linspace(0, B, grid_size)

    F_probs = jnp.array(BetaBinomial(grid_size - 1, F_a, F_b).pdf())
    G_probs = jnp.array(BetaBinomial(grid_size - 1, G_a, G_b).pdf())

    return CareerWorkerProblem(β=β, θ=θ, ϵ=ϵ,
                               F_probs=F_probs, G_probs=G_probs,
                               F_mean=θ @ F_probs, G_mean=ϵ @ G_probs)

贝尔曼算子为 \(Tv(\theta, \epsilon) = \max\{I, II, III\}\),其中 \(I\)\(II\)\(III\)(59.2) 中所给出。

我们先为单个状态 \((\theta_i, \epsilon_j)\) 写出这三个值, 使代码紧密对应方程本身。

def _B(v, cw, i, j):
    """
    状态 (θ_i, ϵ_j) 下三种可选方案的取值,顺序与贝尔曼方程中出现的顺序一致。
    """
    stay_put = cw.θ[i] + cw.ϵ[j] + cw.β * v[i, j]                        # I
    new_job = cw.θ[i] + cw.G_mean + cw.β * v[i, :] @ cw.G_probs          # II
    new_life = cw.G_mean + cw.F_mean + cw.β * cw.F_probs @ v @ cw.G_probs # III
    return jnp.array([stay_put, new_job, new_life])

现在我们在每个状态上评估 _B

与在 \(i\)\(j\) 上写两层嵌套循环不同,我们对 jax.vmap 应用两次。

in_axes 中,0 表示被映射的参数,而 None 表示保持固定的参数。

# _B 的参数顺序为 (v,    cw,   i,    j)
_B_j  = jax.vmap(_B,   in_axes=(None, None, None, 0))   # 对 j 进行映射
_B_ij = jax.vmap(_B_j, in_axes=(None, None, 0,    None))  # 然后对 i 进行映射


@jax.jit
def B(v, cw):
    "每个状态下每个选项的取值;形状为 (grid_size, grid_size, 3)。"
    n = len(cw.θ)
    return _B_ij(v, cw, jnp.arange(n), jnp.arange(n))

现在,贝尔曼算子和贪婪策略分别是同一个数组的最大值和最大值所在的下标。

@jax.jit
def T(v, cw):
    "贝尔曼算子。"
    return jnp.max(B(v, cw), axis=-1)


@jax.jit
def get_greedy(v, cw):
    "v-贪婪策略,编码为 1 = 原地不动,2 = 新工作,3 = 新生活。"
    return jnp.argmax(B(v, cw), axis=-1) + 1

最后,solve_model 通过迭代贝尔曼算子来求出不动点。

我们使用 jax.lax.while_loop,这样整个迭代过程可以编译为单个操作, 并限制迭代步数上限,以确保循环总能终止。

@jax.jit
def solve_model(cw, tol=1e-4, max_iter=1_000):
    """
    通过价值函数迭代求解模型。

    返回价值函数、所用的迭代次数以及最终误差,以便调用者检查收敛情况。
    """
    def condition(loop_state):
        i, v, error = loop_state
        return (error > tol) & (i < max_iter)

    def update(loop_state):
        i, v, error = loop_state
        v_new = T(v, cw)
        return i + 1, v_new, jnp.max(jnp.abs(v_new - v))

    n = len(cw.θ)
    v_init = jnp.full((n, n), 100.0)
    i, v, error = jax.lax.while_loop(condition, update, (0, v_init, tol + 1))
    return v, i, error

备注

这里的网格较小,该模型在 NumPy 中也能运行良好。

我们使用 JAX,是因为其代码可读性几乎与 NumPy 等价的实现一样好, 同时具有更强的扩展性 — 无论是使用更精细的网格,还是使用带有更多状态变量的 更丰富的模型版本,同一份代码都能充分利用 GPU。

练习 59.2 中,我们同时模拟了 25,000 条独立的职业路径, 这种优势已经可以看出来。

这是模型的解决方案 – 一个近似值函数

cw = create_career_worker_problem()
v_star, num_iter, error = solve_model(cw)
greedy_star = get_greedy(v_star, cw)

print(f"Converged in {num_iter} iterations with error {error:.2e}.")

fig = plt.figure(figsize=(8, 6))
ax = fig.add_subplot(111, projection='3d')
tg, eg = jnp.meshgrid(cw.θ, cw.ϵ)
ax.plot_surface(tg,
                eg,
                v_star.T,
                cmap=cm.jet,
                alpha=0.5,
                linewidth=0.25)
ax.set(xlabel='θ', ylabel='ϵ', zlim=(150, 200))
ax.view_init(ax.elev, 225)
plt.show()
Converged in 216 iterations with error 9.16e-05.
_images/9216da286470870b55064d8ec040eae27c8f5cc75301495028e52a6e377b113c.png

这就是最优策略

fig, ax = plt.subplots(figsize=(6, 6))
tg, eg = jnp.meshgrid(cw.θ, cw.ϵ)
lvls = (0.5, 1.5, 2.5, 3.5)
ax.contourf(tg, eg, greedy_star.T, levels=lvls, cmap=cm.winter, alpha=0.5)
ax.contour(tg, eg, greedy_star.T, colors='k', levels=lvls, linewidths=2)
ax.set(xlabel='θ', ylabel='ϵ')
ax.text(1.8, 2.5, '新生活', fontsize=14)
ax.text(4.5, 2.5, '新工作', fontsize=14, rotation='vertical')
ax.text(4.0, 4.5, '原地不动', fontsize=14)
plt.show()
findfont: Failed to find font weight normal, now using 600.
_images/40e7af06f563c8854fd9f07b1cb52def50c952b1311e2690239fcb3ccc9a8542.png

解释:

  • 如果工作和职业都很差或一般,劳动者会尝试新的工作和新的职业。

  • 如果职业足够好,劳动者会保持这个职业,并尝试新的工作直到找到一个足够好的工作。

  • 如果工作和职业都很好,劳动者会原地不动。

注意,劳动者会倾向于保持一个好的职业发展方向,但是高薪工作却不一定会一直做下去。

原因是高终身工资需要职业方向和职业内的工作都很好,而且劳动者不能在不换工作的情况下换职业。

  • 有时必须牺牲一个好工作来转向一个更好的职业。

59.4. 练习#

练习 59.1

使用函数 create_career_worker_problem 中的默认参数设置, 当劳动者遵循最优策略时,生成并绘制 \(\theta\)\(\epsilon\) 的典型样本路径。

特别是,除了随机性之外,复现以下图形(其中横轴表示时间)

_images/career_solutions_ex1_py.png

练习 59.2

现在让我们考虑从起点 \((\theta, \epsilon) = (0, 0)\) 开始,劳动者需要多长时间才能找到一份永久性工作。

换句话说,我们要研究这个随机变量的分布

\[ T^* := \text{劳动者的工作不再改变的第一个时间点} \]

显然,当且仅当 \((\theta_t, \epsilon_t)\) 进入 \((\theta, \epsilon)\) 空间的”原地不动”区域时,劳动者的工作才会变成永久性的。

\(S\) 表示这个区域,\(T^*\) 可以表示为在最优策略下首次到达 \(S\) 的时间:

\[ T^* := \inf\{t \geq 0 \,|\, (\theta_t, \epsilon_t) \in S\} \]

收集这个随机变量的25,000个样本并计算中位数(应该约为7)。

\(\beta=0.99\) 重复这个练习并解释变化。

练习 59.3

将参数设置为 G_a = G_b = 100 并生成一个新的最优策略图 – 解释。