From bb06715e781e40ef5c0594abe89b25a11d9eabda Mon Sep 17 00:00:00 2001 From: John Stachurski Date: Sun, 16 Nov 2025 13:43:28 +0900 Subject: [PATCH 1/7] Add continuous distribution and volatility analysis to McCall model (#707) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit addresses issue #707 by extending the basic McCall model: - Add "Continuous Offer Distribution" section - Move continuous wage distribution from exercises to main content - Implement lognormal wage distribution with Monte Carlo integration - Show how reservation wage varies with c and β using contour plots - Add "Volatility" section - Demonstrate that reservation wage increases with volatility - Use mean-preserving spread with lognormal distribution - Illustrate how workers prefer more volatile distributions - Update exercise mm_ex1 solution - Change from discrete to continuous distribution - Use JAX implementation with continuous wage draws - Remove exercise mm_ex2 (now covered in main text) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- lectures/mccall_model.md | 411 ++++++++++++++++++++------------------- 1 file changed, 210 insertions(+), 201 deletions(-) diff --git a/lectures/mccall_model.md b/lectures/mccall_model.md index febec370e..1b386ff53 100644 --- a/lectures/mccall_model.md +++ b/lectures/mccall_model.md @@ -735,186 +735,16 @@ def compute_reservation_wage_two( You can use this code to solve the exercise below. -## Exercises - -```{exercise} -:label: mm_ex1 - -Compute the average duration of unemployment when $\beta=0.99$ and -$c$ takes the following values - -> `c_vals = np.linspace(10, 40, 25)` - -That is, start the agent off as unemployed, compute their reservation wage -given the parameters, and then simulate to see how long it takes to accept. - -Repeat a large number of times and take the average. - -Plot mean unemployment duration as a function of $c$ in `c_vals`. -``` - -```{solution-start} mm_ex1 -:class: dropdown -``` - -Here's a solution using Numba. - -```{code-cell} ipython3 -# Convert JAX arrays to NumPy arrays for use with Numba -q_default_np = np.array(q_default) -w_default_np = np.array(w_default) -cdf = np.cumsum(q_default_np) - -@numba.jit -def compute_stopping_time(w_bar, seed=1234): - """ - Compute stopping time by drawing wages until one exceeds w_bar. - """ - np.random.seed(seed) - t = 1 - while True: - # Generate a wage draw - w = w_default_np[qe.random.draw(cdf)] - - # Stop when the draw is above the reservation wage - if w >= w_bar: - stopping_time = t - break - else: - t += 1 - return stopping_time - -@numba.jit(parallel=True) -def compute_mean_stopping_time(w_bar, num_reps=100000): - """ - Generate a mean stopping time over `num_reps` repetitions by - drawing from `compute_stopping_time`. - """ - obs = np.empty(num_reps) - for i in numba.prange(num_reps): - obs[i] = compute_stopping_time(w_bar, seed=i) - return obs.mean() - -c_vals = np.linspace(10, 40, 25) -stop_times = np.empty_like(c_vals) -for i, c in enumerate(c_vals): - mcm = McCallModel(c=c) - w_bar = compute_reservation_wage_two(mcm) - stop_times[i] = compute_mean_stopping_time(float(w_bar)) - -fig, ax = plt.subplots() - -ax.plot(c_vals, stop_times, label="mean unemployment duration") -ax.set(xlabel="unemployment compensation", ylabel="months") -ax.legend() - -plt.show() -``` - -And here's a solution using JAX. - -```{code-cell} ipython3 -# First, we set up a function to draw random wage offers from the distribution. -# We use the inverse transform method: draw a uniform random variable u, -# then find the smallest wage w such that the CDF at w is >= u. -cdf = jnp.cumsum(q_default) - -def draw_wage(uniform_rv): - """ - Draw a wage from the distribution q_default using the inverse transform method. - - Parameters: - ----------- - uniform_rv : float - A uniform random variable on [0, 1] - - Returns: - -------- - wage : float - A wage drawn from w_default with probabilities given by q_default - """ - return w_default[jnp.searchsorted(cdf, uniform_rv)] - - -def compute_stopping_time(w_bar, key): - """ - Compute stopping time by drawing wages until one exceeds `w_bar`. - """ - def update(loop_state): - t, key, accept = loop_state - key, subkey = jax.random.split(key) - u = jax.random.uniform(subkey) - w = draw_wage(u) - accept = w >= w_bar - t = t + 1 - return t, key, accept - - def cond(loop_state): - _, _, accept = loop_state - return jnp.logical_not(accept) - - initial_loop_state = (0, key, False) - t_final, _, _ = jax.lax.while_loop(cond, update, initial_loop_state) - return t_final - - -def compute_mean_stopping_time(w_bar, num_reps=100000, seed=1234): - """ - Generate a mean stopping time over `num_reps` repetitions by - drawing from `compute_stopping_time`. - """ - # Generate a key for each MC replication - key = jax.random.PRNGKey(seed) - keys = jax.random.split(key, num_reps) - - # Vectorize compute_stopping_time and evaluate across keys - compute_fn = jax.vmap(compute_stopping_time, in_axes=(None, 0)) - obs = compute_fn(w_bar, keys) +## Continuous Offer Distribution - # Return mean stopping time - return jnp.mean(obs) - -c_vals = jnp.linspace(10, 40, 25) +The discrete wage offer distribution used above is convenient for theory and +computation, but many realistic distributions are continuous (i.e., have a density). -@jax.jit -def compute_stop_time_for_c(c): - """Compute mean stopping time for a given compensation value c.""" - model = McCallModel(c=c) - w_bar = compute_reservation_wage_two(model) - return compute_mean_stopping_time(w_bar) - -# Vectorize across all c values -compute_stop_time_vectorized = jax.vmap(compute_stop_time_for_c) -stop_times = compute_stop_time_vectorized(c_vals) - -fig, ax = plt.subplots() - -ax.plot(c_vals, stop_times, label="mean unemployment duration") -ax.set(xlabel="unemployment compensation", ylabel="months") -ax.legend() - -plt.show() -``` - -At least for our hardware, Numba is faster on the CPU while JAX is faster on the GPU. - -```{solution-end} -``` - -```{exercise-start} -:label: mm_ex2 -``` - -The purpose of this exercise is to show how to replace the discrete wage -offer distribution used above with a continuous distribution. - -This is a significant topic because many convenient distributions are -continuous (i.e., have a density). - -Fortunately, the theory changes little in our simple model. +Fortunately, the theory changes little in our simple model when we shift to a +continuous offer distribution. Recall that $h$ in {eq}`j1` denotes the value of not accepting a job in this period but -then behaving optimally in all subsequent periods: +then behaving optimally in all subsequent periods. To shift to a continuous offer distribution, we can replace {eq}`j1` by @@ -944,28 +774,18 @@ h The aim is to solve this nonlinear equation by iteration, and from it obtain the reservation wage. -Try to carry this out, setting - -* the state sequence $\{ s_t \}$ to be IID and standard normal and -* the wage function to be $w(s) = \exp(\mu + \sigma s)$. +### Implementation with Lognormal Wages -You will need to implement a new version of the `McCallModel` class that -assumes a lognormal wage distribution. +Let's implement this for the case where -Calculate the integral by Monte Carlo, by averaging over a large number of wage draws. +* the state sequence $\{ s_t \}$ is IID and standard normal and +* the wage function is $w(s) = \exp(\mu + \sigma s)$. -For default parameters, use `c=25, β=0.99, σ=0.5, μ=2.5`. +This gives us a lognormal wage distribution. -Once your code is working, investigate how the reservation wage changes with $c$ and $\beta$. - -```{exercise-end} -``` - -```{solution-start} mm_ex2 -:class: dropdown -``` +We use Monte Carlo integration to evaluate the integral, averaging over a large number of wage draws. -Here is one solution: +For default parameters, we use `c=25, β=0.99, σ=0.5, μ=2.5`. ```{code-cell} ipython3 class McCallModelContinuous(NamedTuple): @@ -988,20 +808,20 @@ def create_mccall_continuous( @jax.jit def compute_reservation_wage_continuous(model, max_iter=500, tol=1e-5): c, β, σ, μ, w_draws = model - + h = jnp.mean(w_draws) / (1 - β) # initial guess - + def update(state): h, i, error = state integral = jnp.mean(jnp.maximum(w_draws / (1 - β), h)) h_next = c + β * integral error = jnp.abs(h_next - h) return h_next, i + 1, error - + def cond(state): h, i, error = state return jnp.logical_and(i < max_iter, error > tol) - + initial_state = (h, 0, tol + 1) final_state = jax.lax.while_loop(cond, update, initial_state) h_final, _, _ = final_state @@ -1010,10 +830,8 @@ def compute_reservation_wage_continuous(model, max_iter=500, tol=1e-5): return (1 - β) * h_final ``` -Now we investigate how the reservation wage changes with $c$ and -$\beta$. - -We will do this using a contour plot. +Now let's investigate how the reservation wage changes with $c$ and +$\beta$ using a contour plot. ```{code-cell} ipython3 grid_size = 25 @@ -1053,5 +871,196 @@ ax.ticklabel_format(useOffset=False) plt.show() ``` +As with the discrete case, the reservation wage increases with both patience and unemployment compensation. + +## Volatility + +An interesting feature of the McCall model is that increased volatility in wage offers +tends to increase the reservation wage. + +The intuition is that volatility is attractive to the worker because they can enjoy +the upside (high wage offers) while rejecting the downside (low wage offers). + +Hence, with more volatility, workers are more willing to continue searching rather than +accept a given offer, which means the reservation wage rises. + +To illustrate this phenomenon, we use a mean-preserving spread of the wage distribution. + +In particular, we vary the scale parameter $\sigma$ in the lognormal wage distribution +$w(s) = \exp(\mu + \sigma s)$ while adjusting $\mu$ to keep the mean constant. + +Recall that for a lognormal distribution with parameters $\mu$ and $\sigma$, the mean is +$\exp(\mu + \sigma^2/2)$. + +To keep the mean constant at some value $m$, we need: + +$$ +\mu = \ln(m) - \frac{\sigma^2}{2} +$$ + +Let's implement this and compute the reservation wage for different values of $\sigma$: + +```{code-cell} ipython3 +# Fix the mean wage +mean_wage = 20.0 + +# Create a range of volatility values +σ_vals = jnp.linspace(0.1, 1.0, 25) + +# For each σ, compute μ to maintain constant mean +def compute_μ_for_mean(σ, mean_wage): + return jnp.log(mean_wage) - (σ**2) / 2 + +# Compute reservation wage for each volatility level +res_wages_volatility = [] + +for σ in σ_vals: + μ = compute_μ_for_mean(σ, mean_wage) + model = create_mccall_continuous(σ=float(σ), μ=float(μ)) + res_wage = compute_reservation_wage_continuous(model) + res_wages_volatility.append(res_wage) + +res_wages_volatility = jnp.array(res_wages_volatility) +``` + +Now let's plot the reservation wage as a function of volatility: + +```{code-cell} ipython3 +fig, ax = plt.subplots() + +ax.plot(σ_vals, res_wages_volatility, linewidth=2) +ax.set_xlabel('volatility ($\sigma$)', fontsize=12) +ax.set_ylabel('reservation wage', fontsize=12) +ax.set_title('Reservation wage increases with volatility') +ax.grid(True, alpha=0.3) + +plt.show() +``` + +As expected, the reservation wage is increasing in $\sigma$. + +This confirms that workers prefer more volatile wage distributions, all else equal, +because they can capitalize on high offers while rejecting low ones. + +## Exercises + +```{exercise} +:label: mm_ex1 + +Compute the average duration of unemployment when $\beta=0.99$ and +$c$ takes the following values + +> `c_vals = np.linspace(10, 40, 25)` + +That is, start the agent off as unemployed, compute their reservation wage +given the parameters, and then simulate to see how long it takes to accept. + +Repeat a large number of times and take the average. + +Plot mean unemployment duration as a function of $c$ in `c_vals`. +``` + +```{solution-start} mm_ex1 +:class: dropdown +``` + +Here's a solution using the continuous wage offer distribution with JAX. + +```{code-cell} ipython3 +def compute_stopping_time_continuous(w_bar, key, model): + """ + Compute stopping time by drawing wages from the continuous distribution + until one exceeds `w_bar`. + + Parameters: + ----------- + w_bar : float + The reservation wage + key : jax.random.PRNGKey + Random key for JAX + model : McCallModelContinuous + The model containing wage draws + + Returns: + -------- + t_final : int + The stopping time (number of periods until acceptance) + """ + c, β, σ, μ, w_draws = model + + def update(loop_state): + t, key, accept = loop_state + key, subkey = jax.random.split(key) + # Draw a standard normal and transform to wage + s = jax.random.normal(subkey) + w = jnp.exp(μ + σ * s) + accept = w >= w_bar + t = t + 1 + return t, key, accept + + def cond(loop_state): + _, _, accept = loop_state + return jnp.logical_not(accept) + + initial_loop_state = (0, key, False) + t_final, _, _ = jax.lax.while_loop(cond, update, initial_loop_state) + return t_final + + +def compute_mean_stopping_time_continuous(w_bar, model, num_reps=100000, seed=1234): + """ + Generate a mean stopping time over `num_reps` repetitions. + + Parameters: + ----------- + w_bar : float + The reservation wage + model : McCallModelContinuous + The model containing parameters + num_reps : int + Number of simulation replications + seed : int + Random seed + + Returns: + -------- + mean_time : float + Average stopping time across all replications + """ + # Generate a key for each MC replication + key = jax.random.PRNGKey(seed) + keys = jax.random.split(key, num_reps) + + # Vectorize compute_stopping_time_continuous and evaluate across keys + compute_fn = jax.vmap(compute_stopping_time_continuous, in_axes=(None, 0, None)) + obs = compute_fn(w_bar, keys, model) + + # Return mean stopping time + return jnp.mean(obs) + + +# Compute mean stopping time for different values of c +c_vals = jnp.linspace(10, 40, 25) + +@jax.jit +def compute_stop_time_for_c_continuous(c): + """Compute mean stopping time for a given compensation value c.""" + model = create_mccall_continuous(c=c) + w_bar = compute_reservation_wage_continuous(model) + return compute_mean_stopping_time_continuous(w_bar, model) + +# Vectorize across all c values +compute_stop_time_vectorized = jax.vmap(compute_stop_time_for_c_continuous) +stop_times = compute_stop_time_vectorized(c_vals) + +fig, ax = plt.subplots() + +ax.plot(c_vals, stop_times, label="mean unemployment duration") +ax.set(xlabel="unemployment compensation", ylabel="months") +ax.legend() + +plt.show() +``` + ```{solution-end} ``` From 1c7e29eba048d83a186963af8879f01e65ad7d9c Mon Sep 17 00:00:00 2001 From: John Stachurski Date: Sun, 16 Nov 2025 14:44:34 +0900 Subject: [PATCH 2/7] misc --- lectures/mccall_fitted_vfi.md | 2 +- lectures/mccall_model.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lectures/mccall_fitted_vfi.md b/lectures/mccall_fitted_vfi.md index 9395cab87..8b280c11f 100644 --- a/lectures/mccall_fitted_vfi.md +++ b/lectures/mccall_fitted_vfi.md @@ -44,7 +44,7 @@ $$ and $\{Z_t\}$ is IID and standard normal. -While we already considered continuous wage distributions briefly in Exercise {ref}`mm_ex2` of the {doc}`first job search lecture `, the change was relatively trivial in that case. +While we already considered continuous wage distributions briefly in {doc}``, the change was relatively trivial in that case. The reason is that we were able to reduce the problem to solving for a single scalar value (the continuation value). diff --git a/lectures/mccall_model.md b/lectures/mccall_model.md index 1b386ff53..138ab00b7 100644 --- a/lectures/mccall_model.md +++ b/lectures/mccall_model.md @@ -950,7 +950,7 @@ because they can capitalize on high offers while rejecting low ones. Compute the average duration of unemployment when $\beta=0.99$ and $c$ takes the following values -> `c_vals = np.linspace(10, 40, 25)` +> `c_vals = np.linspace(10, 40, 4)` That is, start the agent off as unemployed, compute their reservation wage given the parameters, and then simulate to see how long it takes to accept. @@ -1040,7 +1040,7 @@ def compute_mean_stopping_time_continuous(w_bar, model, num_reps=100000, seed=12 # Compute mean stopping time for different values of c -c_vals = jnp.linspace(10, 40, 25) +c_vals = jnp.linspace(10, 40, 4) @jax.jit def compute_stop_time_for_c_continuous(c): From 2ea4ce05e58f8b16301ac03e0ce6e108585fd31f Mon Sep 17 00:00:00 2001 From: John Stachurski Date: Sun, 16 Nov 2025 15:00:44 +0900 Subject: [PATCH 3/7] misc --- lectures/mccall_fitted_vfi.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lectures/mccall_fitted_vfi.md b/lectures/mccall_fitted_vfi.md index 8b280c11f..62d035d31 100644 --- a/lectures/mccall_fitted_vfi.md +++ b/lectures/mccall_fitted_vfi.md @@ -44,7 +44,7 @@ $$ and $\{Z_t\}$ is IID and standard normal. -While we already considered continuous wage distributions briefly in {doc}``, the change was relatively trivial in that case. +While we already considered continuous wage distributions briefly in {doc}`mccall_model`, the change was relatively trivial in that case. The reason is that we were able to reduce the problem to solving for a single scalar value (the continuation value). From 905151e6e330948c8c111a611c5b2fee9d2a81c0 Mon Sep 17 00:00:00 2001 From: John Stachurski Date: Sun, 16 Nov 2025 16:39:17 +0900 Subject: [PATCH 4/7] Add lifetime value analysis for volatility in McCall model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add new section showing expected lifetime value increases with volatility - Implement parallel JAX code for simulating job search with optimal policy - Use jax.vmap to vectorize 10,000 simulation replications - Compute reservation wage and lifetime value across volatility levels - Plot demonstrates option value of search under uncertainty - Fix LaTeX escape sequence warnings in xlabel (use raw strings) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- lectures/mccall_model.md | 162 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 156 insertions(+), 6 deletions(-) diff --git a/lectures/mccall_model.md b/lectures/mccall_model.md index 138ab00b7..5f3d3ceff 100644 --- a/lectures/mccall_model.md +++ b/lectures/mccall_model.md @@ -929,21 +929,169 @@ Now let's plot the reservation wage as a function of volatility: fig, ax = plt.subplots() ax.plot(σ_vals, res_wages_volatility, linewidth=2) -ax.set_xlabel('volatility ($\sigma$)', fontsize=12) +ax.set_xlabel(r'volatility ($\sigma$)', fontsize=12) ax.set_ylabel('reservation wage', fontsize=12) -ax.set_title('Reservation wage increases with volatility') -ax.grid(True, alpha=0.3) - plt.show() ``` As expected, the reservation wage is increasing in $\sigma$. -This confirms that workers prefer more volatile wage distributions, all else equal, -because they can capitalize on high offers while rejecting low ones. +### Lifetime Value and Volatility + +We've seen that the reservation wage increases with volatility. Now let's verify that +the lifetime value at the optimal policy also increases with volatility. + +The intuition is that higher volatility provides more upside potential while the +worker can protect against downside risk by rejecting low offers. This option value +translates into higher expected lifetime utility. + +To demonstrate this, we'll: +1. Compute the reservation wage for each volatility level +2. Simulate the worker's job search process following the optimal policy +3. Calculate the expected discounted lifetime income + +The simulation works as follows: starting unemployed, the worker draws wage offers +and accepts the first offer that exceeds their reservation wage. We then compute +the present value of this income stream. + +```{code-cell} ipython3 +@jax.jit +def simulate_lifetime_value(key, model, w_bar, max_search_periods=1000): + """ + Simulate one realization of the job search and compute lifetime value. + + Parameters: + ----------- + key : jax.random.PRNGKey + Random key for JAX + model : McCallModelContinuous + The model containing parameters + w_bar : float + The reservation wage + max_search_periods : int + Maximum number of search periods before forcing acceptance + + Returns: + -------- + lifetime_value : float + Discounted sum of lifetime income + """ + c, β, σ, μ, w_draws = model + + def search_step(state): + t, key, accepted, wage = state + key, subkey = jax.random.split(key) + # Draw wage offer + s = jax.random.normal(subkey) + w = jnp.exp(μ + σ * s) + # Check if we accept + accept_now = w >= w_bar + # Update state: if we accept now, store the wage + wage = jnp.where(accept_now, w, wage) + accepted = jnp.logical_or(accepted, accept_now) + t = t + 1 + return t, key, accepted, wage + + def search_cond(state): + t, _, accepted, _ = state + # Continue searching if not accepted and haven't hit max periods + return jnp.logical_and(jnp.logical_not(accepted), t < max_search_periods) + + # Initial state: period 0, not accepted, wage 0 + initial_state = (0, key, False, 0.0) + t_final, _, _, final_wage = jax.lax.while_loop(search_cond, search_step, initial_state) + + # Compute lifetime value + # During unemployment (periods 0 to t_final-1): receive c each period + # After employment (period t_final onwards): receive final_wage forever + unemployment_value = c * (1 - β**t_final) / (1 - β) + employment_value = (β**t_final) * final_wage / (1 - β) + lifetime_value = unemployment_value + employment_value + + return lifetime_value + + +@jax.jit +def compute_mean_lifetime_value(model, w_bar, num_reps=10000, seed=1234): + """ + Compute mean lifetime value across many simulations. + + Parameters: + ----------- + model : McCallModelContinuous + The model containing parameters + w_bar : float + The reservation wage + num_reps : int + Number of simulation replications + seed : int + Random seed + + Returns: + -------- + mean_value : float + Average lifetime value across all replications + """ + key = jax.random.PRNGKey(seed) + keys = jax.random.split(key, num_reps) + + # Vectorize the simulation across all replications + simulate_fn = jax.vmap(simulate_lifetime_value, in_axes=(0, None, None)) + lifetime_values = simulate_fn(keys, model, w_bar) + + return jnp.mean(lifetime_values) +``` + +Now let's compute both the reservation wage and the expected lifetime value +for each volatility level: + +```{code-cell} ipython3 +# Use the same volatility range and mean wage +σ_vals = jnp.linspace(0.1, 1.0, 25) +mean_wage = 20.0 + +# Storage for results +res_wages_vol = [] +lifetime_values_vol = [] + +for σ in σ_vals: + μ = compute_μ_for_mean(σ, mean_wage) + model = create_mccall_continuous(σ=float(σ), μ=float(μ)) + + # Compute reservation wage + w_bar = compute_reservation_wage_continuous(model) + res_wages_vol.append(w_bar) + + # Compute expected lifetime value + lv = compute_mean_lifetime_value(model, w_bar) + lifetime_values_vol.append(lv) + +res_wages_vol = jnp.array(res_wages_vol) +lifetime_values_vol = jnp.array(lifetime_values_vol) +``` + +Let's visualize the expected lifetime value as a function of volatility: + +```{code-cell} ipython3 +fig, ax = plt.subplots() + +ax.plot(σ_vals, lifetime_values_vol, linewidth=2, color='green') +ax.set_xlabel(r'volatility ($\sigma$)', fontsize=12) +ax.set_ylabel('expected lifetime value', fontsize=12) +plt.show() +``` + +The plot confirms that despite workers setting higher reservation wages when facing +more volatile wage offers (as shown above), they achieve higher expected lifetime +values due to the option value of search. + +This demonstrates a key insight from the McCall model: volatility in wage offers +benefits workers who can optimally time their job acceptance decision. + ## Exercises + ```{exercise} :label: mm_ex1 @@ -958,6 +1106,8 @@ given the parameters, and then simulate to see how long it takes to accept. Repeat a large number of times and take the average. Plot mean unemployment duration as a function of $c$ in `c_vals`. + +Try to explain what you see. ``` ```{solution-start} mm_ex1 From 326b34381f9cfae6d3f13451478718c6e795c83a Mon Sep 17 00:00:00 2001 From: John Stachurski Date: Sun, 16 Nov 2025 16:55:20 +0900 Subject: [PATCH 5/7] Simplify lifetime value simulation to fixed 100 periods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace while_loop with fixed-period simulation (100 periods) - Draw all wage offers upfront using vectorized operations - Use cumsum to track employment status from first acceptance - Simpler logic that's easier to parallelize (same path length) - Cleaner code without nested loop functions - Update description to reflect simplified approach 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- lectures/mccall_model.md | 78 +++++++++++++++++++--------------------- 1 file changed, 37 insertions(+), 41 deletions(-) diff --git a/lectures/mccall_model.md b/lectures/mccall_model.md index 5f3d3ceff..d5dff4729 100644 --- a/lectures/mccall_model.md +++ b/lectures/mccall_model.md @@ -938,25 +938,28 @@ As expected, the reservation wage is increasing in $\sigma$. ### Lifetime Value and Volatility -We've seen that the reservation wage increases with volatility. Now let's verify that -the lifetime value at the optimal policy also increases with volatility. +We've seen that the reservation wage increases with volatility. -The intuition is that higher volatility provides more upside potential while the -worker can protect against downside risk by rejecting low offers. This option value -translates into higher expected lifetime utility. +It's also the case that maximal lifetime value increases with volatility. + +Higher volatility provides more upside potential, while at the same time +workers can protect themselves against downside risk by rejecting low offers. + +This option value translates into higher expected lifetime utility. To demonstrate this, we'll: 1. Compute the reservation wage for each volatility level 2. Simulate the worker's job search process following the optimal policy 3. Calculate the expected discounted lifetime income -The simulation works as follows: starting unemployed, the worker draws wage offers -and accepts the first offer that exceeds their reservation wage. We then compute -the present value of this income stream. +The simulation works as follows: we draw 100 wage offers and track the worker's +earnings at each date. The worker accepts the first offer that exceeds their +reservation wage and earns that wage in all subsequent periods. We then compute +the discounted sum of earnings over these 100 periods. ```{code-cell} ipython3 @jax.jit -def simulate_lifetime_value(key, model, w_bar, max_search_periods=1000): +def simulate_lifetime_value(key, model, w_bar, n_periods=100): """ Simulate one realization of the job search and compute lifetime value. @@ -968,45 +971,38 @@ def simulate_lifetime_value(key, model, w_bar, max_search_periods=1000): The model containing parameters w_bar : float The reservation wage - max_search_periods : int - Maximum number of search periods before forcing acceptance + n_periods : int + Number of periods to simulate Returns: -------- lifetime_value : float - Discounted sum of lifetime income + Discounted sum of income over n_periods """ c, β, σ, μ, w_draws = model - def search_step(state): - t, key, accepted, wage = state - key, subkey = jax.random.split(key) - # Draw wage offer - s = jax.random.normal(subkey) - w = jnp.exp(μ + σ * s) - # Check if we accept - accept_now = w >= w_bar - # Update state: if we accept now, store the wage - wage = jnp.where(accept_now, w, wage) - accepted = jnp.logical_or(accepted, accept_now) - t = t + 1 - return t, key, accepted, wage - - def search_cond(state): - t, _, accepted, _ = state - # Continue searching if not accepted and haven't hit max periods - return jnp.logical_and(jnp.logical_not(accepted), t < max_search_periods) - - # Initial state: period 0, not accepted, wage 0 - initial_state = (0, key, False, 0.0) - t_final, _, _, final_wage = jax.lax.while_loop(search_cond, search_step, initial_state) - - # Compute lifetime value - # During unemployment (periods 0 to t_final-1): receive c each period - # After employment (period t_final onwards): receive final_wage forever - unemployment_value = c * (1 - β**t_final) / (1 - β) - employment_value = (β**t_final) * final_wage / (1 - β) - lifetime_value = unemployment_value + employment_value + # Draw all wage offers upfront + key, subkey = jax.random.split(key) + s_vals = jax.random.normal(subkey, (n_periods,)) + wage_offers = jnp.exp(μ + σ * s_vals) + + # Determine which offers are acceptable + accept = wage_offers >= w_bar + + # Track employment status: employed from first acceptance onward + employed = jnp.cumsum(accept) > 0 + + # Get the accepted wage (first wage where accept is True) + first_accept_idx = jnp.argmax(accept) + accepted_wage = wage_offers[first_accept_idx] + + # Earnings at each period: accepted_wage if employed, c if unemployed + earnings = jnp.where(employed, accepted_wage, c) + + # Compute discounted sum + periods = jnp.arange(n_periods) + discount_factors = β ** periods + lifetime_value = jnp.sum(discount_factors * earnings) return lifetime_value From e609076570e91cdb483243e898a567fb389b8c83 Mon Sep 17 00:00:00 2001 From: John Stachurski Date: Sun, 16 Nov 2025 17:04:48 +0900 Subject: [PATCH 6/7] misc --- lectures/mccall_model.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/lectures/mccall_model.md b/lectures/mccall_model.md index d5dff4729..b4c5db681 100644 --- a/lectures/mccall_model.md +++ b/lectures/mccall_model.md @@ -947,21 +947,24 @@ workers can protect themselves against downside risk by rejecting low offers. This option value translates into higher expected lifetime utility. -To demonstrate this, we'll: +To demonstrate this, we will: + 1. Compute the reservation wage for each volatility level -2. Simulate the worker's job search process following the optimal policy -3. Calculate the expected discounted lifetime income +3. Calculate the expected discounted value of the lifetime income stream + associated with that reservation wage, using Monte Carlo. + +The simulation works as follows: -The simulation works as follows: we draw 100 wage offers and track the worker's -earnings at each date. The worker accepts the first offer that exceeds their -reservation wage and earns that wage in all subsequent periods. We then compute -the discounted sum of earnings over these 100 periods. +1. Compute the present discounted value of one lifetime earnings path, from a given wage path. +2. Average over a large number of such calculations to approximate expected discounted value. + +We truncate each path at $T=100$, which provides sufficient resolution for our purposes. ```{code-cell} ipython3 @jax.jit def simulate_lifetime_value(key, model, w_bar, n_periods=100): """ - Simulate one realization of the job search and compute lifetime value. + Simulate one realization of the wage path and compute lifetime value. Parameters: ----------- @@ -1081,9 +1084,6 @@ The plot confirms that despite workers setting higher reservation wages when fac more volatile wage offers (as shown above), they achieve higher expected lifetime values due to the option value of search. -This demonstrates a key insight from the McCall model: volatility in wage offers -benefits workers who can optimally time their job acceptance decision. - ## Exercises From d3329b903279dca546733a11b6dab3912a4dbb72 Mon Sep 17 00:00:00 2001 From: John Stachurski Date: Sun, 16 Nov 2025 17:29:57 +0900 Subject: [PATCH 7/7] misc --- lectures/mccall_model.md | 42 ++++++---------------------------------- 1 file changed, 6 insertions(+), 36 deletions(-) diff --git a/lectures/mccall_model.md b/lectures/mccall_model.md index b4c5db681..e29581369 100644 --- a/lectures/mccall_model.md +++ b/lectures/mccall_model.md @@ -907,7 +907,7 @@ mean_wage = 20.0 # Create a range of volatility values σ_vals = jnp.linspace(0.1, 1.0, 25) -# For each σ, compute μ to maintain constant mean +# Given σ, compute μ to maintain constant mean def compute_μ_for_mean(σ, mean_wage): return jnp.log(mean_wage) - (σ**2) / 2 @@ -927,7 +927,6 @@ Now let's plot the reservation wage as a function of volatility: ```{code-cell} ipython3 fig, ax = plt.subplots() - ax.plot(σ_vals, res_wages_volatility, linewidth=2) ax.set_xlabel(r'volatility ($\sigma$)', fontsize=12) ax.set_ylabel('reservation wage', fontsize=12) @@ -1015,21 +1014,6 @@ def compute_mean_lifetime_value(model, w_bar, num_reps=10000, seed=1234): """ Compute mean lifetime value across many simulations. - Parameters: - ----------- - model : McCallModelContinuous - The model containing parameters - w_bar : float - The reservation wage - num_reps : int - Number of simulation replications - seed : int - Random seed - - Returns: - -------- - mean_value : float - Average lifetime value across all replications """ key = jax.random.PRNGKey(seed) keys = jax.random.split(key, num_reps) @@ -1037,44 +1021,30 @@ def compute_mean_lifetime_value(model, w_bar, num_reps=10000, seed=1234): # Vectorize the simulation across all replications simulate_fn = jax.vmap(simulate_lifetime_value, in_axes=(0, None, None)) lifetime_values = simulate_fn(keys, model, w_bar) - return jnp.mean(lifetime_values) ``` -Now let's compute both the reservation wage and the expected lifetime value -for each volatility level: +Now let's compute the expected lifetime value for each volatility level: ```{code-cell} ipython3 # Use the same volatility range and mean wage σ_vals = jnp.linspace(0.1, 1.0, 25) mean_wage = 20.0 -# Storage for results -res_wages_vol = [] -lifetime_values_vol = [] - +lifetime_vals = [] for σ in σ_vals: μ = compute_μ_for_mean(σ, mean_wage) - model = create_mccall_continuous(σ=float(σ), μ=float(μ)) - - # Compute reservation wage - w_bar = compute_reservation_wage_continuous(model) - res_wages_vol.append(w_bar) - - # Compute expected lifetime value + model = create_mccall_continuous(σ=σ, μ=μ) lv = compute_mean_lifetime_value(model, w_bar) - lifetime_values_vol.append(lv) + lifetime_vals.append(lv) -res_wages_vol = jnp.array(res_wages_vol) -lifetime_values_vol = jnp.array(lifetime_values_vol) ``` Let's visualize the expected lifetime value as a function of volatility: ```{code-cell} ipython3 fig, ax = plt.subplots() - -ax.plot(σ_vals, lifetime_values_vol, linewidth=2, color='green') +ax.plot(σ_vals, lifetime_vals, linewidth=2, color='green') ax.set_xlabel(r'volatility ($\sigma$)', fontsize=12) ax.set_ylabel('expected lifetime value', fontsize=12) plt.show()