Three separable objects, and keeping them separable is the point
| Object | Says |
|---|---|
SAM | what the base year was |
ModelSpec | what is assumed about substitution and functional form |
Closure | which balances are imposed and who adjusts |
Results can turn on the last two, so both travel with the results instead of living in a branch inside a solver.
The numeraire factor's market is not solved. It is left to Walras' law, so its residual is an independent check on the accounting rather than an identity the solver was told to satisfy. It is recorded on every period.
Install
pip install cgelab
pip install "cgelab[parquet]" # SAMs in .parquet
pip install "cgelab[dsge]" # forward-looking investment, through dsgelab
pip install "cgelab @ git+https://github.com/GustavoHM/cgelab" # the current main branch
For development:
git clone https://github.com/GustavoHM/cgelab && cd cgelab
pip install -e ".[test]" && pytest
cgelab # runs the toy economy and prints the diagnostics
cgelab my_sam.parquet --sigma-armington 2.0 --sigma-va 0.5
Examples
All of them run as they are, right after pip install. They use the toy
economy shipped with the package, written entry by entry so the base year is known on paper. The
test suite runs these same examples.
1. From the SAM to the equilibrium
import cgelab as cge
from cgelab.examples import toy
sam, econ = toy.build() # 2 activities, 2 commodities, 2 factors, 2 households
print(cge.report(econ)) # base year, Walras, homogeneity
base = cge.solve_period(econ)
print(base.summary())
base.pq # consumer prices
base.X # level of each activity
base.walras # the market the solver did NOT solve
cgelab diagnostics -- Economy(2 activities, 2 commodities, 2 households, 2 factors)
base-year reproduction : PASS (worst 3.55e-16)
imports 3.55e-16
exports 1.78e-16
activity_output 1.42e-16
commodity_output 1.42e-16
Walras residual : 0.00e+00 [f_lab]
price homogeneity : PASS (worst 4.44e-16)
equilibrium (baseline): numeraire=1; investment=savings_driven; government=real_fixed; foreign=fixed_saving
residual : 3.553e-16 (converged, cold)
Walras residual : 0.000e+00 [f_lab, not solved for]
price f_lab : 1.000000
price f_cap : 1.000000
exchange rate : 1.000000
savings : 50.0
base.pq
[1.048 1.049]
base.X
[100 200]
base.walras
0
2. A counterfactual
shock = cge.Scenario(name="tax +50%", product_tax=econ.ptax * 1.5)
alt = cge.solve_period(econ, scenario=shock)
alt.pq / base.pq - 1 # price change, commodity by commodity
alt.real["hh_real_income"] # real income by household
abs(alt.walras) < 1e-9 # the accounting still closes
alt.pq / base.pq - 1
[0.03488 0.04116]
alt.real["hh_real_income"]
[78.01 45.91]
abs(alt.walras) < 1e-9
True
3. A consumer wedge, and who gets the revenue back
W = cge.consumer_wedge(econ, {"c_ind": 0.08}) # 8% on one commodity
capital = cge.solve_period(econ, scenario=cge.Scenario(
consumer_wedge=W, wedge_revenue_to="capital")) # returned by capital ownership
percap = cge.solve_period(econ, scenario=cge.Scenario(
consumer_wedge=W, wedge_revenue_to="lumpsum")) # returned per capita
half = cge.solve_period(econ, scenario=cge.Scenario(
consumer_wedge=W, wedge_revenue_to=0.5)) # a convex combination
capital.hh_income[0] > percap.hh_income[0] # the capital-rich household gains
abs(sum(capital.hh_wedge_paid) - capital.wedge_revenue) < 1e-12 # the identity holds, to rounding
capital.hh_income[0] > percap.hh_income[0]
True
abs(sum(capital.hh_wedge_paid) - capital.wedge_revenue) < 1e-12
True
This is not an implementation detail. Capital ownership is
concentrated at the top. Under "capital" the poor pay the wedge on their purchases
and the rich receive it as income, so part of any measured regressivity is manufactured by that
assumption. The convex form exists so the finding gets a magnitude instead of being binary: there
is generally a weight at which the distributional gradient crosses zero, and that fraction says
whether the result has room.
The recycled revenue is an infinite series. Crediting it to income raises spending, which raises the wedge, which raises income again. Demand is affine in spending, so the series is summed in closed form. Iterating it converges, but leaves discontinuities that wreck the outer Newton's Jacobian.
4. A 25-year path
growth = cge.Growth(labour=0.005, tfp=0.010,
depreciation=0.05, discount=0.05)
def policy(year, t):
return cge.Scenario(consumer_wedge=W) if year >= 2026 else cge.Scenario()
ref = cge.run_path(econ, years=range(2021, 2046), growth=growth)
alt = cge.run_path(econ, years=range(2021, 2046), growth=growth, scenario=policy)
ref.aggregates() # prices, real absorption, saving, Walras: one row per year
ref.worst_walras # the number to look at first
ref.capital_stock # the stock, recovered from the user cost and not from the SAM
ref.aggregates()
exchange_rate savings walras residual ... inv_real hh_sav_real gov_sav_real for_sav_real
year ...
2021 1 50 0 3.553e-16 ... 50 35 5 10
2022 0.972 50.83 -2.442e-15 2.292e-16 ... 52.28 36.08 5.92 10.29
2023 0.9446 51.66 -1.151e-15 7.105e-16 ... 54.67 37.2 6.888 10.58
2024 0.9179 52.49 -1.4e-15 2.141e-16 ... 57.14 38.35 7.907 10.89
2025 0.8917 53.31 -1.52e-15 2.066e-16 ... 59.72 39.54 8.978 11.2
2026 0.8663 54.13 -1.008e-15 1.993e-16 ... 62.41 40.78 10.1 11.53
... ... ... ... ... ... ... ... ... ...
2040 0.582 65.26 -1.88e-15 7.105e-16 ... 111.8 62.16 32.49 17.13
2041 0.5665 66.05 -1.52e-15 3.553e-16 ... 116.2 63.99 34.65 17.6
2042 0.5516 66.84 -2.676e-15 7.105e-16 ... 120.8 65.86 36.89 18.08
2043 0.5371 67.63 -2.778e-15 3.553e-16 ... 125.5 67.77 39.21 18.56
2044 0.5232 68.42 -1.843e-15 0 ... 130.4 69.72 41.63 19.06
2045 0.5098 69.21 -1.834e-15 7.105e-16 ... 135.4 71.7 44.14 19.56
ref.worst_walras
3.237e-15
ref.capital_stock
[600 620 641.3 663.9 687.8 713.2 739.9 768.1 797.8 829 861.7 896.1 932 969.7 1009 1050
1093 1137 1184 1232 1282 1334 1388 1445 1503]
A Walras residual that grows along the path is the signature of a coefficient the price block saw and the quantity block did not. It is invisible in a single period and unmistakable over twenty-five years.
5. Welfare, without mistaking composition for welfare
ev = cge.equivalent_variation(ref, alt)
last = ev[ev.year == 2045].set_index("account")
last.ev # valued at the reference scenario's prices
last.ev_base # valued at base-year prices
last.ev_pct_account # by ACCOUNT: read the note below before using it
last.ev
account
hh_1 -0.1148
hh_2 -0.5904
last.ev_base
account
hh_1 -0.2125
hh_2 -1.091
last.ev_pct_account
account
hh_1 -0.09563
hh_2 -1.004
When households migrate between accounts, the per-account measure reports the migration as welfare. The toy economy has a variant with two twin accounts (same preferences, same saving rate) precisely to show this:
sam, econ2 = toy.build_with_twin_accounts() # hh_1, and hh_2 split into hh_2a and hh_2b
moved = econ2.hh_facshare.copy() # move income between the twins, total unchanged
a, b = econ2.household("hh_2a"), econ2.household("hh_2b")
for f in range(econ2.n_fac):
tot = moved[a, f] + moved[b, f]
moved[a, f], moved[b, f] = 0.6 * tot, 0.4 * tot
r1 = cge.run_path(econ2, years=range(2021, 2024))
r2 = cge.run_path(econ2, years=range(2021, 2024),
scenario=cge.Scenario(factor_ownership=moved))
ev2 = cge.equivalent_variation(r1, r2)
ev2.ev_pct_account.abs().max() # > 5%: composition, reported as welfare
groups = {"hh_1": "g1", "hh_2a": "g2", "hh_2b": "g2"}
cge.ev_by_group(ev2, groups).ev_pct.abs().max() # ~0: the zero that is really there
ev2.ev_pct_account.abs().max()
20
cge.ev_by_group(ev2, groups).ev_pct.abs().max()
2.22e-14
ev_by_group sums real incomes within the group before dividing, so the set
of households summed over is the same in both scenarios and the ratio does not depend on how many
migrated. Every account must be in the mapping: dropping one silently would understate the group,
and the error would look like a result.
6. Exports that answer to the world price
import numpy as np
_, econ_cet = toy.build(model_spec=toy.spec(export_rule="cet", sigma_cet=2.0))
j = econ_cet.commodity("c_ind")
pe = np.ones(econ_cet.n_com)
pe[j] = 1.2 # the world pays 20% more for industry
before = cge.solve_period(econ_cet)
after = cge.solve_period(econ_cet, scenario=cge.Scenario(export_price=pe))
after.exports[j] / after.Q[j] # 0.119: the exported share rises from 0.100
after.pe[j] / after.pd[j] # 1.09: exporting now pays more than selling at home
after.exchange_rate # 0.89: the currency appreciates and absorbs part of the shock
after.exports[j] / after.Q[j]
0.1185
after.pe[j] / after.pd[j]
1.089
after.exchange_rate
0.895
Under export_rule="cet" output splits between the home market and exports along a
transformation frontier, and the domestic price is the one at which the frontier's revenue equals
unit cost. Under fixed shares, the default, export_price is refused instead of being
silently ignored.
_, econ_big = toy.build(model_spec=toy.spec(export_rule="cet", sigma_cet=2.0,
export_demand={"c_ind": 2.0}))
big = cge.solve_period(econ_big, scenario=cge.Scenario(export_price=pe))
big.world_export_price[j] # 1.12: selling more moves the buyer down its demand curve
big.exports[j] / econ_big.exp0[j] # 1.14: exports rise 14%, against 22% for the price taker
big.world_export_price[j]
1.124
big.exports[j] / econ_big.exp0[j]
1.14
A region that sells a large share of a product is not a price taker.
ModelSpec.export_demand puts its exports on a demand curve,
E / E0 = (pWe / export_price)^(-η), the form of Hosoe's lrgcge;
import_supply does the same for the world import price. At the base year both
curves pass through the calibrated point.
7. Taxes, transfers and foreign capital
import dataclasses
_, econ_i = toy.build_with_institutions()
econ_i.hh_dtax # direct tax rate by household, read from the SAM
econ_i.fac_row_share # share of each factor owned abroad
econ_i.foreign_sav # 18: the current account, not just the trade balance (10)
growth_i = cge.Growth(labour=0.01, tfp=0.01)
years = range(2021, 2031)
fdi = cge.run_path(econ_i, years=years, growth=growth_i,
scenario=lambda year, t: cge.Scenario(fdi={"f_cap": 3.0}))
gift = cge.run_path(econ_i, years=years, growth=growth_i,
economy=lambda year, t: dataclasses.replace(
econ_i, foreign_sav=econ_i.foreign_sav + 3.0))
fdi[2030].factor_income_abroad > gift[2030].factor_income_abroad # the profits leave
fdi[2030].hh_income.sum() < gift[2030].hh_income.sum() # and local income is lower
econ_i.hh_dtax
[0.1154 0.03226]
econ_i.fac_row_share
[0 0.1667]
econ_i.foreign_sav
18
fdi[2030].factor_income_abroad > gift[2030].factor_income_abroad
True
fdi[2030].hh_income.sum() < gift[2030].hh_income.sum()
True
The same capital inflow, read two ways. As fdi, it finances investment and becomes
foreign-owned capital, whose return leaves through the current account. As plain foreign saving,
the capital stays with residents and every gain it produces is credited to them. It is the
difference between measuring the effect of a foreign investment and overstating it.
8. Several capital stocks and a technology that changes over time
_, econ_k = toy.build_with_specific_capital() # industry uses capital of its own
g = cge.Growth(capital_factor=("f_cap", "f_kind"),
depreciation={"f_cap": 0.05, "f_kind": 0.08},
allocation=2.0) # investment follows the relative rent
def economy(year, t): # industry saves 2% of farm input a year
io = econ_k.io.copy()
io[econ_k.commodity("c_agr"), econ_k.activity("a_ind")] *= 0.98 ** t
return dataclasses.replace(econ_k, io=io)
path = cge.run_path(econ_k, years=range(2021, 2036), growth=g, economy=economy)
path.capital_stock[-1] # one stock per capital factor
path.worst_walras # still at machine precision
path.capital_stock[-1]
[359.1 379.2]
path.worst_walras
3.359e-15
A change that is not a price, a tax or an endowment (a technology being adopted, a financing
plan) enters through the economy argument, which returns each year's economy. That is
what an energy-transition scenario needs: the input coefficients change, and the price and
quantity blocks keep seeing the same technology.
9. With your own SAM
The toy economy exists so the examples run. With your own data, what changes is the first two
lines and the ModelSpec:
sam = cge.SAM.read("my_sam.parquet") # or SAM.from_long(df) / SAM.from_square(df)
sam.check_balance() # a hard gate: nothing runs on an unbalanced SAM
spec = cge.ModelSpec(
activity_prefix="a_", commodity_prefix="c_", household_prefix="hh_",
factors=("f_lab", "f_cap"),
government="gov", investment="s_i", rest_of_world="row",
numeraire_factor="f_lab", # its market is left to Walras' law
value_added="ces", sigma_va=0.5,
sigma_armington=2.0, # a scalar, or a mapping by commodity
export_rule="fixed_shares", # or "cet": input-output sources supply no CET elasticity
household_demand="cobb_douglas", # or "les", with the Frisch parameter
negative_value_added="leontief", # a negative operating surplus is data, not an error
)
econ = cge.calibrate(sam, spec, population={"hh_d1": 8.3e6, "hh_d2": 8.3e6})
assert cge.base_year_reproduction(econ)["ok"] # do not go on without this
assert cge.homogeneity_test(econ)["ok"]
econ.diagnostics["unmodelled_flows"] # SAM cells the model does not read
Accounts are found by prefix, or by explicit lists. Explicit lists are the safer choice for a SAM whose naming is not systematic: a prefix rule that silently misses an account produces a model that balances and is wrong.
10. Multipliers, with every price fixed
sam_toy, _ = toy.build()
m = cge.accounting_multipliers(sam_toy, spec=toy.spec()) # gov, investment, rest of world exogenous
m.M.loc["hh_2", "c_agr"] # 0.546: what one unit of demand for agriculture adds to household 2's income
paths = cge.structural_paths(m, "c_agr", "hh_2", threshold=1e-3)
paths[["path", "total", "share"]].head(2)
# (c_agr, a_agr, f_lab, hh_2) 0.323 0.59 through wages, with the feedback off the path
# (c_agr, a_agr, f_cap, hh_2) 0.131 0.24
m.M.loc["hh_2", "c_agr"]
0.5463
paths[["path", "total", "share"]].head(2)
path total share
0 (c_agr, a_agr, f_lab, hh_2) 0.3227 0.5907
1 (c_agr, a_agr, f_cap, hh_2) 0.1312 0.2401
The same SAM read as a fixed-price model: quantities answer to an injection in base-year
proportions. leontief_multipliers keeps only production endogenous,
fixed_price_multipliers puts households on marginal propensities, and
linkages gives the backward and forward key-sector table. Over every elementary
path, the total influences add up to the multiplier.
11. Accounts
taxed = cge.solve_period(econ, scenario=cge.Scenario(product_tax=econ.ptax * 1.5))
na = cge.national_accounts(taxed) # the counterfactual of example 2
na["gdp_expenditure"], na["gdp_income"] # 202.78 and 202.78: GDP from both sides, in any equilibrium
pi = cge.price_indices(taxed)
pi.loc["gdp", ["fisher", "quantity_fisher"]] # 1.040, 0.9999: prices up 4%, volume flat
cge.government_account(taxed)["saving"] # 12.04, against 5 in the base year
na["gdp_expenditure"], na["gdp_income"]
(202.8, 202.8)
pi.loc["gdp", ["fisher", "quantity_fisher"]]
fisher 1.04
quantity_fisher 0.9999
cge.government_account(taxed)["saving"]
12.04
GDP at current prices from the expenditure and the income side, Laspeyres, Paasche and Fisher indices for each component of final demand, GDP and the terms of trade, and the government account by instrument. The government account is recorded by the solver at the solution, so its saving is the one that financed investment.
12. How much a result rests on its elasticities
run = cge.spec_sweep(sam, toy.spec(), scenario=cge.Scenario(product_tax=econ.ptax * 1.5),
measure=lambda base, alt: {"gdp_pct": 100 * (
cge.real_gdp(alt)["expenditure"] / cge.real_gdp(base)["expenditure"] - 1)})
params = {"sigma_va": cge.Uniform(0.3, 1.3), "sigma_armington": cge.Uniform(1.5, 3.5)}
cge.sensitivity(run, params).summary() # Stroud quadrature: 4 points for 2 parameters
cge.sensitivity(run, params, method="monte_carlo", draws=40).summary()
cge.sensitivity(run, params).summary()
mean sd
gdp_pct -0.008932 0.001384
cge.sensitivity(run, params, method="monte_carlo", draws=40).summary()
mean sd q0.05 q0.5 q0.95 min max
gdp_pct -0.008932 0.00138 -0.01149 -0.008389 -0.007568 -0.012 -0.007453
Each point recalibrates and solves base and scenario. Stroud's rule gives a mean exact up to
cubic in the parameters at 2n solves; Monte Carlo adds quantiles. An Armington
elasticity near 1 is kept out of the range on purpose: under fixed export shares the exchange
rate is not determined at 1, and badly conditioned near it.
13. Which shock did what
_, econ_tt = toy.build_with_trade_taxes()
reform = cge.Scenario(tariff=np.zeros(2), world_price=np.array([1.1, 0.95]),
product_tax=econ_tt.ptax * 1.2)
parts = cge.subtotals(econ_tt, cge.Scenario(), reform,
{"tariff": ["tariff"], "world": ["world_price"], "tax": ["product_tax"]},
measure=lambda p: {"gdp": cge.real_gdp(p)["expenditure"]})
parts[["tariff", "world", "tax", "change", "residual"]]
# gdp -0.723 -0.125 -0.007 -0.855 ~1e-10 the parts add up to the solved change
parts[["tariff", "world", "tax", "change", "residual"]]
tariff world tax change residual
gdp -0.7235 -0.1245 -0.006899 -0.8549 -2.806e-10
The decomposition GEMPACK calls subtotals. Every shock moves together along the straight line from the reference to the alternative, and each group's term is integrated on its own, so the parts add up to the total without depending on any order.
14. Where a welfare change came from
from cgelab.examples import hosoe
_, lrg = hosoe.build(large_country=True)
w = cge.welfare_decomposition(lrg, cge.Scenario(), cge.Scenario(tariff=np.zeros(2)),
closure=hosoe.CLOSURE)
w["allocative_tariff"], w["terms_of_trade"] # +0.054, -0.831: the gain from the tariff, and what a large country pays for it abroad
w["absorption_households"] # +0.506: Hosoe's EV for the same experiment is +0.508
w["residual"] # ~1e-10: the terms add up to the change in absorption
w["allocative_tariff"], w["terms_of_trade"]
(0.05421, -0.8315)
w["absorption_households"]
0.506
w["residual"]
-3.304e-10
Huff and Hertel's decomposition, written for this model's instruments: endowment, technology, one allocative term per distortion, terms of trade and the external account, integrated along the path. The measure is real absorption, government and investment included. Its household part is the one to set against an EV.
15. A carbon price, recycled
import pandas as pd
_, inst = toy.build_with_institutions()
fuel = pd.DataFrame(0.0, index=inst.coms, columns=inst.acts)
fuel.loc["c_ind", ["a_agr", "a_ind"]] = [3.0, 12.0] # tonnes burnt in production
green = cge.add_emissions(inst, intermediate=fuel, household={"c_ind": 5.0},
process={"a_ind": 2.0})
neutral = cge.Closure(government_target="revenue", government_adjusts="product_tax")
r = cge.solve_period(green, neutral, cge.Scenario(carbon_price=0.5))
cge.emissions(r)["total"] # 21.79, from 22 at the base year
r.tax_scale # 0.26: the carbon revenue pays for cutting product tax rates by 74%
cge.emissions(r)["total"]
21.79
r.tax_scale
0.2648
Emissions are a satellite account calibrated from base-year totals by source. A carbon price is a tax per unit of output by emission intensity on activities and a specific tax on household fuel. Under a revenue target, another tax moves to keep receipts where they were; here it is the product tax.
16. Fuel that answers to the price
_, nested = toy.build_with_institutions(model_spec=toy.spec(energy=["c_ind"], sigma_vae=0.6))
nested = cge.add_emissions(nested, intermediate=fuel, household={"c_ind": 5.0},
process={"a_ind": 2.0})
r2 = cge.solve_period(nested, neutral, cge.Scenario(carbon_price=0.5))
cge.emissions(r2)["total"] # 20.85, against 21.79 with fixed fuel coefficients
r2.input_coefficients[1] # fuel per unit of output: 0.089 and 0.276, from 0.095 and 0.286
cge.emissions(r2)["total"]
20.85
r2.input_coefficients[1]
[0.08882 0.2762]
With ModelSpec.energy, each activity buys an energy bundle that substitutes for
value added. The carbon on a fuel enters the price that activity pays for it, so the same carbon
price now changes how things are produced, not only how much. With both elasticities at zero the
nest is the Leontief model, to machine precision.
17. Labour that moves only partway
pools = pd.DataFrame([[30.0, 10.0], [10.0, 60.0]], index=["rural", "urban"],
columns=["a_agr", "a_ind"]) # who worked where in the base year
spec_m = toy.spec(numeraire_factor="f_cap", factor_mobility={"f_lab": 2.0})
econ_m = cge.calibrate(sam_toy, spec_m, factor_pools={"f_lab": pools})
dear = cge.Scenario(world_price=np.array([1.0, 1.3])) # imported manufactures 30% dearer
r_m = cge.solve_period(econ_m, scenario=dear)
r_m.factor_prices_by_activity[0] # the wage in agriculture and in industry: 0.985 and 1.015
r_m.factor_pools["f_lab"]["price"] # what each pool earns: 0.993 rural, 1.010 urban
r_m.factor_prices_by_activity[0]
[0.9853 1.015]
r_m.factor_pools["f_lab"]["price"]
[0.9928 1.01]
With factor_mobility, labour is split among activities by a CET with
elasticity 2, and each activity pays its own wage. With every factor mobile the same shock raises
the one wage to 1.004. The pools of origin, a proximity matrix, show who gains: the urban
pool works mostly in industry, which the shock expands. At elasticity zero the model is labour
split into one factor per activity, and factor_mobility_form="logit" keeps the head
count instead of the CET frontier.
18. A formal wage floor, and where the rationed go
_, econ_s = toy.build_with_segmented_labour() # formal (industry) and informal (farm) labour
floor = cge.LabourMarket(rigid={"f_for": 1.1}, informal="f_inf") # formal real wage 10% up
r_s = cge.solve_period(econ_s, cge.Closure(labour_markets={"labour": floor}))
m = r_s.labour_markets["labour"]
m["employment"] # formal 66.2 and informal 43.8, from 70 and 40
m["wage"] # 1.097 formal, 0.864 informal
m["household_employment"][0] # hh_1: 56.8 formal, 13.2 informal, from 60 and 10
queue = cge.LabourMarket(rigid={"f_for": 1.1}, informal="f_inf",
unemployment="harris_todaro", unemployment_rate=0.08)
r_q = cge.solve_period(econ_s, cge.Closure(labour_markets={"labour": queue}))
r_q.labour_markets["labour"]["unemployment_rate"] # 0.196, from 0.08
spec_b = toy.spec(factors=("f_cap", "f_for", "f_inf"), numeraire_factor="f_cap",
labour_segments={"labour": ["f_for", "f_inf"]},
segment_mobility={"labour": 2.0}) # a CET between the segments
_, econ_b = toy.build_with_segmented_labour(model_spec=spec_b)
own_queue = cge.LabourMarket(rigid={"f_for": 1.1}, unemployment_rate=0.08)
r_b = cge.solve_period(econ_b, cge.Closure(labour_markets={"labour": own_queue}))
r_b.labour_markets["labour"]["queue"] # 18.6 queue for formal jobs, from 6.1
m["employment"]
[66.21 43.79]
m["wage"]
[1.097 0.8637]
m["household_employment"][0]
[56.75 13.25]
r_q.labour_markets["labour"]["unemployment_rate"]
0.1962
r_b.labour_markets["labour"]["queue"]
[18.59 0]
The two labour accounts share one population. In a dual market everyone the formal sector
stops hiring works informally, and the informal wage falls; under Harris–Todaro they queue for
formal jobs until the informal wage equals the formal wage they expect. Either way the jobs
hh_1 loses reappear as its own informal work or unemployment, not as income of the
households that were informal already. With segment_mobility the workers are split
between the segments by a CET instead, the informal wage clears on its own, and those the
formal sector does not hire queue for it.
19. Poverty and inequality, in a survey's households
survey = toy.survey() # twelve synthetic households, written by hand
ref_s = cge.solve_period(econ_s)
alt_s = cge.solve_period(econ_s, cge.Closure(labour_markets={"labour": floor}))
micro = cge.microsimulate(survey, ref_s, alt_s)
micro.reconciliation # hh_1's formal wages scaled by 60/51 to the SAM
micro.calibration # weights that move formal jobs as the model does
cge.inequality(micro) # Gini 0.531 to 0.556, Theil T 0.527 to 0.577
cge.poverty(micro, 3.0) # headcount 0.698 to 0.731, poverty gap 0.459 to 0.490
micro.reconciliation
group source survey sam factor
0 hh_1 f_cap 32 45 1.406
1 hh_1 f_for 51 60 1.176
2 hh_1 f_inf 6.5 10 1.538
3 hh_2 f_cap 8.75 15 1.714
4 hh_2 f_for 10.5 10 0.9524
5 hh_2 f_inf 30.5 30 0.9836
micro.calibration
group constraint base target reached iterations
0 hh_1 households 7 7 7 5
1 hh_1 f_cap 45 45 45 5
2 hh_1 f_for 60 56.75 56.75 5
3 hh_1 f_inf 10 13.25 13.25 5
4 hh_2 households 11 11 11 4
5 hh_2 f_cap 15 15 15 4
6 hh_2 f_for 10 9.459 9.459 4
7 hh_2 f_inf 30 30.54 30.54 4
cge.inequality(micro)
ref alt change
gini 0.5307 0.5558 0.02518
theil_t 0.5269 0.5765 0.04967
theil_l 0.4877 0.5436 0.05598
ge_2 0.8169 0.8888 0.07193
atkinson_0.5 0.228 0.2496 0.02156
atkinson_1 0.3859 0.4194 0.03343
atkinson_2 0.5538 0.591 0.03719
cge.poverty(micro, 3.0)
ref alt change
line alpha
3.0 0 0.6978 0.7309 0.03307
1 0.4588 0.4899 0.03111
2 0.325 0.3529 0.02792
Each survey household belongs to a SAM account: its income from each source moves as its account's does, and its own budget sets its price index. The survey is first scaled to the SAM, source by source, and the factors are reported rather than hidden. Who loses a formal job enters through the weights: within each account they are recalibrated so that the survey's formal and informal employment move as the model's, which is what moves the headcount.
20. People who choose: a behavioural microsimulation
people = cge.microsimulate(survey, ref_s, alt_s, persons=toy.persons()) # 42 synthetic persons
people.workers # hh_1: formal 7.5 to 7.09, informal 2.5 to 3.31 persons
cge.inequality(people).loc["gini"] # 0.526 to 0.542
drawn = cge.microsimulate(survey, ref_s, alt_s, persons=toy.persons(), method="draws",
replications=200, seed=1)
cge.inequality(drawn).loc["gini"] # 0.526 to 0.544, standard error 0.0008
people.workers
group column workers ref alt
0 hh_1 f_for f_for 7.5 7.094
1 hh_1 f_inf f_inf 2.5 3.311
2 hh_1 inactive inactive 9.5 9.095
3 hh_1 unemployed unemployed 2.5 2.499
4 hh_2 f_for f_for 5 4.73
5 hh_2 f_inf f_inf 19 19.34
6 hh_2 inactive inactive 16.5 16.39
7 hh_2 unemployed unemployed 7 7.035
cge.inequality(people).loc["gini"]
ref 0.5263
alt 0.5421
change 0.01584
cge.inequality(drawn).loc["gini"]
ref 0.5263
alt 0.5437
alt_se 0.0007506
change 0.01743
An occupational choice (a multinomial logit) and earnings equations are estimated on the persons, and the model reaches them through intercepts moved, account by account, until employment and earnings per worker change as the model says, as in Bourguignon, Robilliard and Robinson (2005). The default uses the exact expectation of their conditional draws; the draws themselves give a Monte Carlo mean and its error.
21. The IBGE's surveys
survey, persons = cge.pnadc("PNADC_032023.txt", "input_PNADC_trimestral.txt",
segments={"formal": "f_for", "informal": "f_inf"},
group=my_accounts)
households = cge.pof("Dados_POF/", "Leitura dos Microdados - SAS.txt",
commodities=correspondence, group=my_accounts)
sut = cge.read_tru("68_tab1_2021.xls", "68_tab2_2021.xls") # GDP 2021: R$ 9,012,142 million
The PNAD Contínua and the POF are read with the IBGE's own SAS reading programs, so no column position is typed by hand. Workers are formal or informal by the IBGE's proxy, and the POF's spending is annualised and deflated as the IBGE's tabulation programs do. The microdata are downloaded from the IBGE and never enter the repository.
22. A SAM from supply and use tables
make = pd.DataFrame({"c_x": [100.0]}, index=["a_x"])
use = pd.DataFrame({"a_x": [0.0]}, index=["c_x"])
final = pd.DataFrame({"hh": [80.0], "s_i": [20.0]}, index=["c_x"])
va = pd.DataFrame({"a_x": [60.0, 40.0]}, index=["f_lab", "f_cap"])
sut = cge.SupplyUse(make=make, use=use, final=final, imports=pd.Series({"c_x": 0.0}),
value_added=va)
built = cge.sut_to_sam(sut, source="hand-written tables")
built.provenance[["source", "note", "total"]] # household saving: residual, 20
cge.base_year_reproduction(cge.calibrate(built, cge.ModelSpec(households=["hh"])))["ok"]
built.provenance[["source", "note", "total"]]
source note total
0 hand-written tables value added 100
1 hand-written tables output, basic prices 100
2 hand-written tables final demand: hh 80
3 hand-written tables final demand: s_i 20
4 assumed: all factor income to households factor income 100
5 residual household saving 20
6 structure empty account 0
cge.base_year_reproduction(cge.calibrate(built, cge.ModelSpec(households=["hh"])))["ok"]
True
The supply and use tables become the production block and final demand; who receives the income comes from the integrated economic accounts, here assumed, and the provenance says so. Savings are declared residuals, and tables that do not add up are refused where they fail.
23. Formal and informal labour, split by activity
shares = pd.DataFrame([[0.3, 0.8], [0.7, 0.2]], index=["f_for", "f_inf"],
columns=["a_agr", "a_ind"]) # from a survey: segment_shares(...)
split = cge.split_factor(sam_toy, "f_lab", shares)
two = cge.calibrate(split, toy.spec(factors=("f_cap", "f_for", "f_inf"),
numeraire_factor="f_cap",
labour_segments={"labour": ["f_for", "f_inf"]}))
cge.solve_period(two).residual # the base year, at machine precision
cge.solve_period(two).residual
3.384e-16
Labour is split into formal and informal in each activity by survey shares, and the two
accounts become a group of segments. With one wage for both, the split model has exactly the
single factor's equilibrium; a rigid formal wage then makes the difference.
split_households does the same for a household account and a survey's groups.
24. A baseline that hits a GDP path
gdp = pd.Series([0.046, 0.029, 0.032, 0.025], index=range(2022, 2026)) # real growth
base = cge.calibrate_baseline(econ, years=range(2021, 2026), gdp=gdp,
growth=cge.Growth(labour=0.01))
base.factor # the productivity that hits each year
policy = base.run(cge.Scenario(product_tax=econ.ptax * 1.2))
cge.real_gdp(policy[2025])["expenditure"] / base.achieved[2025] - 1
base.factor
2021 1
2022 1.017
2023 1.023
2024 1.031
2025 1.035
cge.real_gdp(policy[2025])["expenditure"] / base.achieved[2025] - 1
0.004055
Real GDP is given, observed or projected, and productivity is the unknown, solved year by year because each year's capital comes from the investment before it: one number a year, or one per activity on sectoral value added. A policy then runs at the calibrated productivity, and GDP moves away from the path; with no policy, the baseline comes back exactly.
25. Putty-clay capital
vint = cge.split_vintages(sam_toy, 0.08) # 8% of each activity's capital is new
clay = cge.calibrate(vint, toy.spec(factors=("f_lab", "f_cap", "f_cap_new"),
vintages={"f_cap": "f_cap_new"}))
path = cge.run_path(clay, years=range(2021, 2031),
scenario=cge.Scenario(world_price=np.array([1.0, 1.15])))
path.capital_by_pool["f_cap"].iloc[[0, -1]] # installed capital, by activity
path[2021].vintage_value_added # old and new bundles
path.capital_by_pool["f_cap"].iloc[[0, -1]]
a_agr a_ind
2021 230 322
2030 300.2 444.9
path[2021].vintage_value_added
[[59.8 96.6]
[4.306 9.294]]
Installed capital stays in its sector and keeps the technique it was built with; new capital goes where it earns most. When industry's price rises, only the new vintage follows at first, so output answers less than with mobile capital, and catches up as industry installs more of each year's investment.
The three gates
They are not formalities. A model that fails the first one is not calibrated.
# cge.report(econ) on toy.build_with_margins()
cgelab diagnostics -- Economy(3 activities, 3 commodities, 2 households, 2 factors)
base-year reproduction : PASS (worst 7.11e-16)
imports 7.11e-16
activity_output 4.26e-16
commodity_output 4.26e-16
exports 3.55e-16
Walras residual : -2.41e-16 [f_lab]
price homogeneity : PASS (worst 3.72e-16)
| Gate | What it catches |
|---|---|
| Base-year reproduction | With no shock the model must return the SAM: every price one, every quantity at its base value. This catches a units error in the calibration (a purchaser price treated as a basic price, a tax levied on the wrong base) before it is absorbed into a result. |
| Walras' law | A nonzero residual means income is being lost or manufactured somewhere, and the size of the leak bounds nothing about the size of the error it causes. |
| Homogeneity | Scaling the numeraire must leave every real magnitude untouched. A failure means a nominal magnitude denominated in the numeraire was held fixed while the price level moved: foreign saving, a trade-balance target, a specific tax that enters additively. Each shifts a real allocation, and none is visible in the output. |
What is in the box
SAM
Read in long or square form, a balance check as a hard gate, and a sign-preserving RAS for the negative cells real SAMs have.
Calibration
Coefficients at basic prices, margin block, negative value added, LES subsistence, own-account activities, and the list of SAM cells the model does not read.
Production
Leontief between the intermediate bundle and value added, with CES, Cobb–Douglas or Leontief in the factor nest.
Trade
Armington CES; exports by fixed shares or by CET with a world export price.
Institutions
Direct taxes, government transfers, remittances, factor income of government and abroad; foreign saving is the current account.
Dynamics
Recursive, with one or several capital stocks, investment shared by return, FDI that builds foreign-owned capital, and a per-year economy.
Closures
Investment, government and the external sector, each with its own adjusting variable. Five combinations tested.
Welfare
EV and CV, with composition-immune group aggregation and the two price yardsticks made explicit.
JuMP replica
The same equilibrium written as a JuMP model with no nesting, to check the algebra with Ipopt.
A caution the default encodes
The transformation elasticity (CET) is the one parameter of a standard CGE that input–output
sources do not supply. Fixing export shares keeps it out of the baseline entirely, so no headline
number rests on an invented value. export_rule="cet" solves the full transformation
frontier for when an exercise needs exports to respond to price. It is a methodological position,
and the default encodes it.
Validation
Tests on hand-written SAMs
Eight toy economies with every entry written in the code, so the comparison is against arithmetic and not against previous output:
- the plain case;
- one with a national-accounts margin block (margin services sold into the supply column of the other commodities, against a negative entry on their own diagonal);
- one with an own-account activity;
- one with twin accounts, for the composition test;
- one with direct taxes, transfers, remittances, and capital owned by government and abroad;
- one with sector-specific capital;
- one with a tariff and an indirect tax on output inside the prices;
- a closed one-good economy with Cobb–Douglas production, which is the stochastic growth model the bridge to dsgelab is checked against.
The tests cover base-year reproduction to machine precision, homogeneity, and Walras under a shock in every closure combination and at every date of a growing path. They also cover the CET revenue identity and the foreign ownership of capital bought with FDI. And every executable example on this page runs in the suite.
An independent JuMP replica
to_jump writes the same equilibrium as a JuMP model in which nothing is nested: the
price fixed point, the wedge series and the saving–revenue relation are plain constraints, and
Ipopt solves them from a displaced start. In 50 cases (margins, own-account activities, CET, export demand and import supply curves, tariffs and output taxes inside the prices, Hosoe's closure, government targets, a carbon price, two energy nests, direct
taxes, transfers, foreign capital, FDI, LES, imperfect factor mobility, segmented labour,
capital vintages and alternative closures) the two solvers agree to at
most 1.2e-11. Perturbing the exported data makes the comparison diverge by 1e-3 to
3e-1, which shows it can see an error.
How it compares with what exists
Here the difference is not a matter of opinion: it is one of scale.
| Capability | cgelab | pycge | PSLmodels/CGE | python-cge |
|---|---|---|---|---|
| Demonstrated scale | applied SAM not public yet | teaching | 16×18 SAM 4 sectors | 2 goods |
| Origin | applied SAM | port of splcge.gams | Hosoe et al. (2010) | Hosoe et al., p. 72 |
| Trade and transport margin block | yes | ? | — | — |
| Negative value added handled | yes | ? | — | — |
| Own-account activities | yes | — | — | — |
| Alternative closures | 5 combinations | ? | — | — |
| Walras as a check, not an equation | yes | ? | — | — |
| Automatic base-year and homogeneity tests | yes | partial | — | — |
| CET solved, with a world export price | yes | ? | yes | — |
| Direct taxes, transfers, foreign capital and FDI | yes | ? | ? | ? |
| Recursive dynamics | several capital stocks | — | yes | — |
| Welfare (EV, CV, by group) | yes | — | — | — |
Applied CGE work is done in GAMS or GEMPACK, and for good
reasons: dedicated solvers, a community, databases such as GTAP. The three Python CGE packages
found are implementations of the textbook model of Hosoe, Gasawa and Hashimoto (2010) or of the
GAMS library's splcge, with two to four sectors. The
PSLmodels/CGE README calls it "very preliminary"
and asks not to be cited.
The defensible claim for cgelab is narrow: for an applied-scale CGE in
Python, with no commercial licence and with the accounting gates built in, no alternative was
found. That is not the same as covering what GAMS covers: there are no multiple regions, no
estimation of elasticities, and no thirty years of use. The full table, marking what was verified
by reading code and what was only read in documentation, is in
docs/comparison.md.
Citing
If the package went into a published result, it goes into the references. Software is a methodological input like any other, and the version matters: a number that only reproduces under one version is not reproducible without it.
BibTeX
@software{cgelab_2026,
author = {Moreira, Gustavo Hoffmann},
title = {{cgelab}: a general toolkit for SAM-based computable general equilibrium models in Python},
year = {2026},
version = {0.5.5},
license = {MIT},
doi = {10.5281/zenodo.23458851},
url = {https://github.com/GustavoHM/cgelab},
note = {Python package}
}
APA
Moreira, G. H. (2026). cgelab: A general toolkit for SAM-based computable general equilibrium
models in Python (Version 0.5.5) [Computer software]. Zenodo. https://doi.org/10.5281/zenodo.23458851
In the text
Cite the version next to the result, not only the name:
The model was calibrated and solved with cgelab 0.5.5
(Moreira, 2026), as a recursive-dynamic path from 2021 to 2045.
Cite the archived version. A repository URL is not an
archivable reference: the content changes and the link dies. Each release of cgelab is archived on
Zenodo, and the DOI that Zenodo assigns to it is the reference
to cite: version 0.5.0 is 10.5281/zenodo.23458852, and the concept DOI
10.5281/zenodo.23458851 resolves to the latest release. The
CITATION.cff file at the package root, which carries the author's ORCID and the
concept DOI, is in the format GitHub reads, and the "Cite this repository" button serves the
entry from it.
And the tools this package uses
NumPy and SciPy ask to be cited and carry everything here; pandas reads and holds the SAM. If the result was checked with the replica, JuMP and Ipopt go in as well.
Documentation
User guide
From the SAM to welfare, with the traps at each step.
API reference
Every function, every field, every array shape.
Design notes
The accounting decisions and the errors each one prevents.
Porting a pipeline
A
translation table from an existing solver to Scenario and Closure.
Comparison
Where the package stands against other Python CGEs and GAMS, and where it does not win.
Changelog
What changed from one version to the next.
Roadmap
What is still missing, and why it matters.
Replications
Published models rerun from their own social accounting matrix, and where a result cannot be claimed yet.