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
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
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
sum(capital.hh_wedge_paid) == capital.wedge_revenue # the identity holds exactly
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
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)
ev.ev # valued at the reference scenario's prices
ev.ev_base # valued at base-year prices
ev.ev_pct_account # by ACCOUNT: read the note below before using it
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
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
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
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
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
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
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
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()
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
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
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%
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
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.
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 34 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 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.0},
license = {MIT},
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.0) [Computer software]. https://github.com/GustavoHM/cgelab
In the text
Cite the version next to the result, not only the name:
The model was calibrated and solved with cgelab 0.5.0
(Moreira, 2026), as a recursive-dynamic path from 2021 to 2045.
Archive before citing. A repository URL is not an archivable
reference: the content changes and the link dies. Connect the repository to
Zenodo, cut a release, and cite the DOI that comes out
of it. The CITATION.cff file at the package root, which already carries the author's ORCID,
is in the format GitHub reads, and the "Cite this repository" button serves the entry from it. The
DOI will be added to that file with the first archived release.
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.