-
Notifications
You must be signed in to change notification settings - Fork 1
feat(analysis): make the two explanations of the N change reproducible #877
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
WesIngwersen
wants to merge
1
commit into
main
Choose a base branch
from
n-change-decomposition-script
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
255 changes: 255 additions & 0 deletions
255
bedrock/analysis/nowcasting/results/n_change_decomposition.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,255 @@ | ||
| """Why N moved, two complementary ways. | ||
|
|
||
| Both tables come from one derivation of B and A, because deriving them takes | ||
| minutes and the two views have to describe the same build. | ||
|
|
||
| **Table 1 - which input moved it.** ``N`` changes for two independent reasons | ||
| and the headline number cannot tell them apart: **A**, the technology matrix | ||
| the nowcast rebuilds from annual data, and **B**, the emission intensities that | ||
| move when the GHG FBS series is regenerated. Holding one at the baseline and | ||
| moving the other separates them. | ||
|
|
||
| ⚠️ The two middle rows do not sum to the first. ``N = 1'B (I-A)^-1`` is not | ||
| additive in A and B, so the remainder is their interaction: emissions moving in | ||
| sectors whose input structure also moved. Quote the rows, not a sum. | ||
|
|
||
| ``D = 1'B`` is the control on the B row. If B moves ``D`` and ``N`` alike, the | ||
| emissions change is a level shift; if ``D`` moves much more, the change sits in | ||
| sectors nothing else depends on, so it never propagates. | ||
|
|
||
| **Table 2 - the mechanism, in units you can say out loud.** ``N`` factors | ||
| exactly into two parts, per sector:: | ||
|
|
||
| N_j = sum_i d_i L_ij | ||
| = (sum_i L_ij) x (sum_i d_i L_ij / sum_i L_ij) | ||
| = output multiplier x average intensity of what is pulled | ||
|
|
||
| So a fall in N is either "a dollar drags less of the economy behind it" | ||
| (multiplier) or "what it drags is cleaner" (average intensity), and this says | ||
| how much of each. The median column sum of ``A`` is reported alongside as the | ||
| thing the multiplier is built from. | ||
|
|
||
| CLI:: | ||
|
|
||
| uv run python -m bedrock.analysis.nowcasting.results.n_change_decomposition | ||
| uv run python -m bedrock.analysis.nowcasting.results.n_change_decomposition \\ | ||
| --year 2023 --baseline v0_3_1 --csv | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import argparse | ||
| import typing as ta | ||
| from pathlib import Path | ||
|
|
||
| import pandas as pd | ||
|
|
||
| from bedrock.analysis.nowcasting.results._ef_smoke_lib import ( | ||
| NOWCAST_COMPARE_YEAR, | ||
| V03_SNAPSHOT, | ||
| _as_float_series, | ||
| activate_live_config, | ||
| config_stem, | ||
| ) | ||
| from bedrock.utils.math.formulas import ( | ||
| compute_d, | ||
| compute_L_matrix, | ||
| compute_M_matrix, | ||
| compute_n, | ||
| ) | ||
| from bedrock.utils.snapshots import releases | ||
| from bedrock.utils.snapshots.loader import load_snapshot | ||
|
|
||
| OUT_DIR = Path(__file__).resolve().parent | ||
|
|
||
|
|
||
| class Build(ta.NamedTuple): | ||
| """Baseline and live matrices on one shared sector axis.""" | ||
|
|
||
| B_old: pd.DataFrame | ||
| B_new: pd.DataFrame | ||
| A_old: pd.DataFrame | ||
| A_new: pd.DataFrame | ||
| vintage: str | ||
| sectors: pd.Index | ||
| dropped: int | ||
|
|
||
|
|
||
| def _n_from(B: pd.DataFrame, A: pd.DataFrame) -> pd.Series: | ||
| """``N`` for one (B, A) pairing, through the formulas the model uses.""" | ||
| L = compute_L_matrix(A=A) | ||
| return _as_float_series(compute_n(M=compute_M_matrix(B=B, L=L))) | ||
|
|
||
|
|
||
| def _median_pct(new: pd.Series, base: pd.Series) -> float: | ||
| """Median percent change, dropping sectors the baseline puts at zero.""" | ||
| both = new.index.intersection(base.index) | ||
| rel = (new.loc[both] - base.loc[both]) / base.loc[both].replace(0.0, pd.NA) | ||
| return float(pd.to_numeric(rel, errors='coerce').dropna().median() * 100.0) | ||
|
|
||
|
|
||
| def load_build(year: int, baseline_key: str) -> Build: | ||
| """Derive the live build once and align it to the baseline's sector axis.""" | ||
| from bedrock.transform.eeio.derived import ( # noqa: PLC0415 | ||
| derive_Aq_usa, | ||
| derive_B_usa_non_finetuned, | ||
| ) | ||
|
|
||
| B_old = load_snapshot('B_USA_non_finetuned', baseline_key) | ||
| Adom_old = load_snapshot('Adom_USA', baseline_key) | ||
| Aimp_old = load_snapshot('Aimp_USA', baseline_key) | ||
|
|
||
| vintage = activate_live_config(year) | ||
| B_new = derive_B_usa_non_finetuned() | ||
| aq = derive_Aq_usa() | ||
|
|
||
| # One sector axis for every combination, or the mixed pairings compare | ||
| # different economies rather than different builds. | ||
| sectors = ( | ||
| Adom_old.index.intersection(aq.Adom.index) | ||
| .intersection(B_old.columns) | ||
| .intersection(B_new.columns) | ||
| ) | ||
| dropped = len(aq.Adom.index) - len(sectors) | ||
|
|
||
| def sq(frame: pd.DataFrame) -> pd.DataFrame: | ||
| return frame.reindex(index=sectors, columns=sectors).fillna(0.0) | ||
|
|
||
| def cols(frame: pd.DataFrame) -> pd.DataFrame: | ||
| return frame.reindex(columns=sectors).fillna(0.0) | ||
|
|
||
| return Build( | ||
| B_old=cols(B_old), | ||
| B_new=cols(B_new), | ||
| # The model's L uses domestic plus imported requirements together. | ||
| A_old=sq(Adom_old) + sq(Aimp_old), | ||
| A_new=sq(aq.Adom) + sq(aq.Aimp), | ||
| vintage=vintage, | ||
| sectors=sectors, | ||
| dropped=dropped, | ||
| ) | ||
|
|
||
|
|
||
| def ab_decomposition(b: Build, label: str) -> pd.DataFrame: | ||
| """Table 1: hold one of A / B at the baseline and move the other.""" | ||
| base = _n_from(b.B_old, b.A_old) | ||
| return pd.DataFrame( | ||
| [ | ||
| { | ||
| 'what moves': 'both B and A', | ||
| 'median change in N': _median_pct(_n_from(b.B_new, b.A_new), base), | ||
| }, | ||
| { | ||
| 'what moves': f'A only, B held at {label}', | ||
| 'median change in N': _median_pct(_n_from(b.B_old, b.A_new), base), | ||
| }, | ||
| { | ||
| 'what moves': f'B only, A held at {label}', | ||
| 'median change in N': _median_pct(_n_from(b.B_new, b.A_old), base), | ||
| }, | ||
| { | ||
| 'what moves': "direct intensity D = 1'B", | ||
| 'median change in N': _median_pct( | ||
| _as_float_series(compute_d(B=b.B_new)), | ||
| _as_float_series(compute_d(B=b.B_old)), | ||
| ), | ||
| }, | ||
| ] | ||
| ) | ||
|
|
||
|
|
||
| def multiplier_intensity(b: Build) -> pd.DataFrame: | ||
| """Table 2: N as output multiplier x average intensity of what is pulled.""" | ||
|
|
||
| def parts(B: pd.DataFrame, A: pd.DataFrame) -> dict[str, pd.Series]: | ||
| L = compute_L_matrix(A=A) | ||
| N = _as_float_series(compute_n(M=compute_M_matrix(B=B, L=L))) | ||
| multiplier = L.sum(axis=0).astype(float) | ||
| return { | ||
| 'a_colsum': A.sum(axis=0).astype(float), | ||
| 'multiplier': multiplier, | ||
| 'avg_intensity': N / multiplier.replace(0.0, pd.NA), | ||
| 'N': N, | ||
| } | ||
|
|
||
| old, new = parts(b.B_old, b.A_old), parts(b.B_new, b.A_new) | ||
| rows = [ | ||
| ('intermediate input per $ of output (col sum of A)', 'a_colsum'), | ||
| ('output multiplier (col sum of L)', 'multiplier'), | ||
| ('avg intensity of what a $ pulls, kg CO2e/$', 'avg_intensity'), | ||
| ('N, kg CO2e/$', 'N'), | ||
| ] | ||
| return pd.DataFrame( | ||
| [ | ||
| { | ||
| 'quantity': label, | ||
| 'baseline': float(pd.to_numeric(old[key], errors='coerce').median()), | ||
| 'nowcast': float(pd.to_numeric(new[key], errors='coerce').median()), | ||
| 'change %': _median_pct(new[key], old[key]), | ||
| } | ||
| for label, key in rows | ||
| ] | ||
| ) | ||
|
|
||
|
|
||
| def main() -> None: | ||
| ap = argparse.ArgumentParser( | ||
| description='Explain the change in N: A vs B, and multiplier vs intensity.' | ||
| ) | ||
| ap.add_argument('--year', type=int, default=NOWCAST_COMPARE_YEAR) | ||
| ap.add_argument( | ||
| '--baseline', | ||
| default=V03_SNAPSHOT, | ||
| help='snapshot key to hold the other side at (default: the v0.3 release)', | ||
| ) | ||
| ap.add_argument( | ||
| '--csv', action='store_true', help='also write CSVs beside this file' | ||
| ) | ||
| args = ap.parse_args() | ||
|
|
||
| baseline = getattr(releases, args.baseline, args.baseline) | ||
| b = load_build(args.year, baseline) | ||
| short = str(baseline)[:7] | ||
|
|
||
| print(f'\nnowcast {config_stem(args.year)} vs snapshot {baseline}') | ||
| print( | ||
| f'MUT vintage {b.vintage} {len(b.sectors)} sectors on both sides' | ||
| + (f' ({b.dropped} dropped)' if b.dropped else '') | ||
| ) | ||
|
|
||
| t1 = ab_decomposition(b, short) | ||
| print('\n--- which input moved N ---') | ||
| print( | ||
| t1.to_string(index=False, formatters={'median change in N': '{:+.2f}%'.format}) | ||
| ) | ||
| print( | ||
| 'The middle two rows do not sum to the first: N is not additive in A ' | ||
| 'and B,\nso the remainder is their interaction.' | ||
| ) | ||
|
|
||
| t2 = multiplier_intensity(b) | ||
| print('\n--- the mechanism: N = output multiplier x average intensity ---') | ||
| print( | ||
| t2.to_string( | ||
| index=False, | ||
| formatters={ | ||
| 'baseline': '{:.4f}'.format, | ||
| 'nowcast': '{:.4f}'.format, | ||
| 'change %': '{:+.2f}%'.format, | ||
| }, | ||
| ) | ||
| ) | ||
| print( | ||
| 'Medians are taken per row, so the two components do not multiply to ' | ||
| 'the N row\nexactly; they are the size of each effect, not an identity.' | ||
| ) | ||
|
|
||
| if args.csv: | ||
| for name, table in (('ab', t1), ('mechanism', t2)): | ||
| path = OUT_DIR / f'n_change_{name}_{args.year}.csv' | ||
| table.to_csv(path, index=False) | ||
| print(f'wrote {path}') | ||
|
|
||
|
|
||
| if __name__ == '__main__': | ||
| main() | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this comes from _ef_smoke_lib which still pints the v0.3.1 model, which doesn't include the MECS update. We need to bump that to v0.3.2.
There may be other related updates as well needed alongside this