Hierarchical ODE State Space Model for Unemployment Dynamics
A Bayesian Approach with Pooled Labor Market Parameters
Published
June 5, 2026
WORK IN PROGRESS
This report is currently under development. Not all statements, interpretations, and conclusions have been reviewed or validated by a human researcher. Please treat all content as preliminary analysis subject to revision.
Overview
This report presents a hierarchical Bayesian state space model for unemployment dynamics across education levels. The model uses a time-varying equilibrium — constructed from a B-spline basis — that the ODE pulls unemployment toward. Shocks are cleanly identified as temporary deviations from this structural baseline.
Spline-Based Equilibrium - B-spline basis (K=10) constructs smooth time-varying \(u_{\text{eq}}(t)\) - Random walk prior on spline coefficients for adaptive smoothness - Each education level has its own equilibrium trajectory with data-informed flexibility
Technical Approach - Non-centered parameterization for efficient MCMC sampling - Asymmetric shock shapes (power-law rise, stretched-exponential decay) for flexible impulse modeling - Education-level parallelization via Stan’s reduce_sum for multi-threaded sampling - Prior-centered initialization via make_init_at_prior_edu_parallel()
Recent Trends: December 2023 - April 2026
A close-up view of the most recent unemployment dynamics across education levels.
Show code
# Extract de-seasonalized trend for recent trends plottrend_all <-extract_trend(result, summary =TRUE)# Filter to December 2023 - April 2026# Formula: year_frac = year + (month - 0.5) / 12# December 2023 = 2023 + 11.5/12 ≈ 2023.958# April 2026 = 2026 + 3.5/12 ≈ 2026.292recent_trend <-data.frame(year_frac = trend_all$year_frac,education = trend_all$education,mean = trend_all$mean,lower = trend_all$q5,upper = trend_all$q95)# Add observed ratesfor (i inseq_along(stan_data$education_levels)) { edu <- stan_data$education_levels[i] idx <- recent_trend$education == edu obs_rate <- stan_data$n_unemployed[, i] / stan_data$n_total[, i] recent_trend$observed[idx] <- obs_rate}# Filter to recent periodrecent_trend <- recent_trend[recent_trend$year_frac >=2023.958& recent_trend$year_frac <=2026.292, ]
The hierarchical structure pools information across education levels. The between-education standard deviation (\(\sigma\)) parameters quantify how much groups differ. Smaller \(\sigma\) = stronger pooling.
sigma_plot <-data.frame(parameter =factor(sigma_display$Parameter, levels =rev(sigma_display$Parameter)),median = sigma_summary$median,q5 = sigma_summary$q5,q95 = sigma_summary$q95)ggplot(sigma_plot, aes(x = median, y = parameter)) +geom_point(size =3, color ="steelblue") +geom_errorbarh(aes(xmin = q5, xmax = q95), height =0.2, color ="steelblue") +labs(x ="Between-Education SD", y =NULL,title ="Hierarchical Pooling Strength",subtitle ="Smaller values = education groups are more similar") +theme_minimal(base_size =13)
Between-education standard deviations with 90% credible intervals
Education-Specific Parameter Values
Parameter estimates for key quantities across all seven education levels:
Show code
# Build per-edu parameter tableedu_params <-rbind(data.frame(result$fit$summary(variables =paste0("adj_speed[", 1:N_edu, "]")),param ="Adjustment Speed", edu = edu_levels),data.frame(result$fit$summary(variables =paste0("shock_2008_effect[", 1:N_edu, "]")),param ="2008 Shock Effect", edu = edu_levels),data.frame(result$fit$summary(variables =paste0("shock_2020_effect[", 1:N_edu, "]")),param ="2020 Shock Effect", edu = edu_levels),data.frame(result$fit$summary(variables =paste0("decay_2008[", 1:N_edu, "]")),param ="2008 Decay Rate", edu = edu_levels),data.frame(result$fit$summary(variables =paste0("decay_2020[", 1:N_edu, "]")),param ="2020 Decay Rate", edu = edu_levels),data.frame(result$fit$summary(variables =paste0("u_eq_mean[", 1:N_edu, "]")),param ="Mean Equilibrium", edu = edu_levels))ggplot(edu_params, aes(x = median, y = edu)) +geom_point(size =2.5, color ="steelblue") +geom_errorbarh(aes(xmin = q5, xmax = q95), height =0.2, color ="steelblue") +facet_wrap(~ param, scales ="free_x", ncol =2) +labs(y =NULL, x ="Posterior median with 90% CI",title ="Education-Specific Parameter Estimates") +theme_minimal(base_size =12)
Key parameter estimates by education level with 90% credible intervals
Key Insights
Adjustment speeds increase with education: less_than_hs has the slowest adjustment, PhD and professional degrees the fastest — consistent with more fluid labor markets at higher education levels.
Shock effects are tightly pooled across education groups — the Exponential(20) prior on \(\sigma\) reflects the economic prior that macro shocks affect all workers similarly. The model estimates similar shock magnitudes across groups.
Mean equilibrium unemployment ranges from ~1.3% (professional degrees) to ~10.2% (less than high school) — matching the raw data well while the time-varying equilibrium captures structural trends within each group.
Economic Parameter Estimates
Adjustment Speed and Equilibrium
The model estimates education-specific adjustment speeds and time-averaged equilibrium unemployment rates.
eq_df <-data.frame(education = edu_levels,median = eq_summary$median,lower = eq_summary$q5,upper = eq_summary$q95)eq_df$education <-factor(eq_df$education,levels = eq_df$education[order(eq_df$median)])ggplot(eq_df, aes(x = education, y = median *100)) +geom_point(size =4, color ="steelblue") +geom_errorbar(aes(ymin = lower *100, ymax = upper *100),width =0.2, linewidth =1, color ="steelblue") +coord_flip() +labs(x ="Education Level",y ="Mean Equilibrium Unemployment Rate (%)",title ="Structural Unemployment Rates by Education",subtitle ="Time-averaged u_eq(t) — the baseline toward which the ODE pulls") +theme(axis.text.y =element_text(size =11))
Time-averaged equilibrium unemployment rates by education level
Shock Effects
The key advantage of the state space model: shock effects are positive and identifiable. With hierarchical priors, we estimate both population-level means and education-specific effects.
The seasonal effect is the difference between the full model (with seasonality) and the trend (without). This directly shows how much unemployment oscillates due to seasonal hiring patterns.
Show code
# Extract seasonal effects directly computed by the modelseasonal_effects <-extract_seasonal_effects(result, summary =TRUE)# Focus on key education levelssample_edu <-c("phd", "bachelors", "less_than_hs")seas_subset <- seasonal_effects[seasonal_effects$education %in% sample_edu, ]# Plot the seasonal oscillation over timeggplot(seas_subset, aes(x = year_frac)) +geom_ribbon(aes(ymin = q5 *100, ymax = q95 *100, fill = education),alpha =0.3) +geom_line(aes(y = mean *100, color = education), linewidth =0.6) +geom_hline(yintercept =0, linetype ="dashed", color ="gray40") +facet_wrap(~education, scales ="free_y", ncol =1) +scale_x_continuous(breaks =seq(2000, 2025, 5)) +labs(x ="Year",y ="Seasonal Effect (percentage points)",title ="Seasonal Oscillation in Unemployment by Education",subtitle ="Positive = unemployment above trend; Negative = below trend; Band = 90% CI") +theme(legend.position ="none",strip.text =element_text(size =12, face ="bold"))
Seasonal oscillation in unemployment rates (Full model minus Trend)
Monthly Seasonal Pattern
The model includes a direct seasonal effect on unemployment (on the logit scale). This captures observed seasonal patterns like academic hiring calendars, summer employment fluctuations, and annual hiring cycles.