English · Português

cgelab

You supply a social accounting matrix. The package calibrates a standard nested CGE from it, solves each period, runs a recursive-dynamic path and measures welfare. The accounting checks that make any of it believable are built in rather than left to the user.

github.com/GustavoHM/cgelab

Python 3.11+ numpy, scipy, pandas Walras as a check 5 closures CET, FDI, several capital stocks checked against JuMP to 1.2e-11 MIT · v0.5.0

Three separable objects, and keeping them separable is the point

ObjectSays
SAMwhat the base year was
ModelSpecwhat is assumed about substitution and functional form
Closurewhich 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)
GateWhat it catches
Base-year reproductionWith 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' lawA 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.
HomogeneityScaling 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.

CapabilitycgelabpycgePSLmodels/CGEpython-cge
Demonstrated scaleapplied SAM
not public yet
teaching16×18 SAM
4 sectors
2 goods
Originapplied SAMport of splcge.gamsHosoe et al. (2010)Hosoe et al., p. 72
Trade and transport margin blockyes?——
Negative value added handledyes?——
Own-account activitiesyes———
Alternative closures5 combinations?——
Walras as a check, not an equationyes?——
Automatic base-year and homogeneity testsyespartial——
CET solved, with a world export priceyes?yes—
Direct taxes, transfers, foreign capital and FDIyes???
Recursive dynamicsseveral 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},
  doi     = {10.5281/zenodo.23458852},
  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]. Zenodo. https://doi.org/10.5281/zenodo.23458852

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.

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.