Crude: Which variables are the strongest predictors for supply and demand?¶
This notebook ranks individual predictors for next-month U.S. supply and demand.
Supply and demand are separate targets.
FUEL = 'crude'
# Load the scientific stack used by the analysis. FUEL is the only setting that
# differs between the crude and gasoline versions of this notebook.
from pathlib import Path
import sys, hashlib, platform
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from IPython.display import display
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import Ridge
import sklearn
# Show all ranking rows and keep the long, descriptive predictor names readable.
pd.set_option('display.max_rows', 60)
pd.set_option('display.max_colwidth', 100)
# Locate the shared project root whether the notebook is launched from this
# product folder or from a parent directory, then make both data loaders importable.
ROOT = next(p for p in [Path.cwd(), *Path.cwd().parents] if (p/'oil/us_snd_crude/crude_data.py').exists())
for fuel in ['crude', 'gasoline']:
sys.path.insert(0, str(ROOT/f'oil/us_snd_{fuel}'))
from crude_data import load_panel as load_crude, FLOWS as CRUDE_FLOWS
from gasoline_data import load_panel as load_gasoline, FLOWS as GAS_FLOWS
# Load monthly PADD-level histories for both products. The second product is
# needed later for cross-market predictors such as crude inputs for gasoline.
panels = {'crude': load_crude(), 'gasoline': load_gasoline()}
national = {}
for fuel, panel in panels.items():
# A national observation is valid only when all five PADD regions are present.
assert panel.groupby('month').padd.nunique().eq(5).all()
cols = (CRUDE_FLOWS + ['stock_kb', 'cdu_capacity_kbd']) if fuel == 'crude' else GAS_FLOWS + ['stock_kb']
# min_count=5 prevents a missing PADD from silently producing a partial U.S. total.
# asfreq also exposes any missing month in the national time series.
z = panel.groupby('month')[cols].sum(min_count=5).asfreq('MS')
assert z.notna().all().all()
# Crude flows already arrive as daily rates. Gasoline flows are stored as
# monthly thousand-barrel totals, so divide by the exact days in each month.
# Stocks are point-in-time levels and are converted from thousand to million barrels.
if fuel == 'gasoline':
for c in GAS_FLOWS:
z[c.replace('_kb', '_kbd')] = z[c] / z.index.days_in_month
z['stock_mb'] = z.stock_kb / 1000
z['change_mb'] = z.stock_mb.diff()
# These derived series summarize trade and the simplified physical balance.
# The core balance excludes adjustments, transfers and other secondary flows.
z['net_imports_kbd'] = z.imports_kbd - z.exports_kbd
z['core_balance_kbd'] = z.production_kbd + z.net_imports_kbd - z.demand_kbd
if fuel == 'crude':
# Refinery utilization compares actual crude inputs with operable capacity.
z['utilization'] = z.demand_kbd / z.cdu_capacity_kbd
national[fuel] = z
# Record the data range and source-file hash so an executed notebook identifies
# the exact local EIA snapshots used to produce its rankings.
manifest = []
for fuel in national:
path = ROOT/f'oil/us_snd_{fuel}/data/eia_observations.csv'
manifest.append({'fuel': fuel, 'start': national[fuel].index.min(), 'end': national[fuel].index.max(),
'months': len(national[fuel]), 'sha256': hashlib.sha256(path.read_bytes()).hexdigest()})
How variable importantce is measured¶
We test each variable separately while accounting for seasonal pattersn (which variables are most important other than season. Controlling for season). Variables that reduce prediction errors the most are the most important.
The test uses six historical periods, with each prediction based only on earlier data.
We remove each variable from the model containing all inputs and refit to measure the information it adds alongside the other variables.
# Select the product being ranked and align the other product to the same calendar.
z = national[FUEL]
other_fuel = 'gasoline' if FUEL == 'crude' else 'crude'
other = national[other_fuel].reindex(z.index)
demand_name = 'Crude refinery inputs' if FUEL == 'crude' else 'Gasoline product supplied'
production_name = 'Crude production' if FUEL == 'crude' else 'Gasoline net production'
# Rank predictors separately for gross supply and demand. Gross supply includes
# domestic production and imports; exports remain an explanatory outflow.
targets = pd.DataFrame({
'Supply: production + imports': z.production_kbd + z.imports_kbd,
'Demand: ' + demand_name.lower(): z.demand_kbd,
})
# Twelve month indicators form the seasonality-only benchmark. Each later
# single-variable test asks whether a predictor improves on this calendar model.
X = pd.DataFrame(index=z.index)
for month in range(1, 13):
X[f'Calendar month {month:02}'] = (X.index.month == month).astype(float)
calendar = list(X.columns)
def history(name, series):
"""Add one-month and smoothed three-month lags without using forecast-month data."""
# Shift before rolling: for a forecast at month t, these features end at t-1.
X[f'{name} — previous month'] = series.shift(1)
X[f'{name} — previous 3-month average'] = series.shift(1).rolling(3).mean()
# Add recent histories of the product's main supply, trade and demand variables.
history(production_name, z.production_kbd)
history(FUEL.title() + ' imports', z.imports_kbd)
history(FUEL.title() + ' exports', z.exports_kbd)
history(demand_name, z.demand_kbd)
history('Supply (production + imports)', targets.iloc[:, 0])
history('Core balance (production + imports − exports − demand)', z.core_balance_kbd)
# Inventory features describe the latest level, recent changes, and the deviation
# from the same point one year earlier. Both levels in the last feature precede t.
X['Inventory level — previous month'] = z.stock_mb.shift(1)
X['Inventory change — previous month'] = z.change_mb.shift(1)
X['Inventory change — previous 3-month average'] = z.change_mb.shift(1).rolling(3).mean()
X['Inventory level minus year-earlier level'] = z.stock_mb.shift(1) - z.stock_mb.shift(13)
# Include the remaining balance components available for each product.
for column, name in [('adjustments_kbd', 'Adjustments'), ('net_receipts_kbd', 'Net regional receipts')]:
history(name, z[column])
if FUEL == 'crude':
history('Transfers', z.transfers_kbd)
history('Direct crude use', z.direct_use_kbd)
else:
history('Biofuel production', z.biofuels_kbd)
# Refinery operations can affect crude demand and gasoline output, so both
# notebooks receive lagged crude utilization and physical distillation capacity.
crude = national['crude'].reindex(z.index)
X['Crude refinery utilization — previous month'] = crude.utilization.shift(1)
X['Crude distillation capacity — previous month'] = crude.cdu_capacity_kbd.shift(1)
# Cross-product features test whether activity in the linked petroleum market
# contains information beyond the selected product's own recent history.
other_demand = 'Gasoline product supplied' if other_fuel == 'gasoline' else 'Crude refinery inputs'
other_production = 'Gasoline net production' if other_fuel == 'gasoline' else 'Crude production'
X[f'{other_demand} — previous month'] = other.demand_kbd.shift(1)
X[f'{other_production} — previous month'] = other.production_kbd.shift(1)
X[f'{other_fuel.title()} inventory change — previous month'] = other.change_mb.shift(1)
# Use one common sample for every comparison. This prevents a feature from looking
# better merely because it was evaluated on a different set of months.
keep = X.notna().all(axis=1) & targets.notna().all(axis=1)
X, targets = X.loc[keep], targets.loc[keep]
# Exclude March 2020 through March 2021 and any target whose lag window reaches
# that disruption. Offsets 0..13 cover the target month and the longest lag used.
eligible = pd.Series(True, index=X.index)
for offset in range(14):
dates = X.index - pd.DateOffset(months=offset)
eligible &= ~((dates >= pd.Timestamp('2020-03-01')) & (dates <= pd.Timestamp('2021-03-01')))
# Score the latest 72 eligible observations in six consecutive 12-month blocks.
# Every block is trained on all eligible observations strictly before that block.
scored_dates = X.index[eligible][-72:]
assert len(scored_dates) == 72
# A block can span a calendar gap because excluded COVID-related dates are absent.
folds = [pd.DatetimeIndex(d) for d in np.array_split(scored_dates, 6)]
features = [c for c in X if c not in calendar]
# Fail early if alignment, missing values, or duplicate feature names could make
# the ranking invalid or silently change the observations being compared.
assert X.index.is_monotonic_increasing and not X.index.has_duplicates
assert np.isfinite(X.to_numpy()).all() and np.isfinite(targets.to_numpy()).all()
assert len(features) == len(set(features))
periods = pd.DataFrame([{
'Period': i + 1, 'First scored month': dates.min().strftime('%Y-%m'),
'Last scored month': dates.max().strftime('%Y-%m'), 'Scored months': len(dates),
'Earlier training months': int(((X.index < dates.min()) & eligible).sum()),
} for i, dates in enumerate(folds)])
# Require a meaningful training history even for the earliest validation block.
assert periods['Earlier training months'].min() >= 36
# Keep regularization fixed so alpha is not tuned on the periods used to rank
# predictors. Standardization puts differently scaled inputs on comparable footing.
ALPHA = 10
def errors(y, columns):
"""Return out-of-sample absolute errors from expanding-window validation."""
blocks = []
for period, dates in enumerate(folds, 1):
# Training uses only eligible observations before the first scored month.
# This chronological split prevents future observations from leaking backward.
train = (X.index < dates.min()) & eligible
assert X.index[train].max() < dates.min()
model = make_pipeline(StandardScaler(), Ridge(alpha=ALPHA))
model.fit(X.loc[train, columns], y.loc[train])
prediction = model.predict(X.loc[dates, columns])
blocks.append(pd.DataFrame({
'period': period,
# Absolute error keeps the result in the target's thousand-b/d units.
'error': np.abs(y.loc[dates].to_numpy() - prediction),
}, index=dates))
return pd.concat(blocks)
rankings, calendar_importance = {}, []
for target_name in targets:
y = targets[target_name]
# These two reference models support the two definitions of importance:
# calendar-only for individual value, and all inputs for conditional value.
calendar_error = errors(y, calendar)
full_error = errors(y, list(X.columns))
rows = []
for feature in features:
# Individual test: add one feature to the calendar-only baseline.
added = errors(y, calendar + [feature])
# Conditional test: remove one feature from the full model and refit.
removed = errors(y, [c for c in X if c != feature])
# Positive gain means the feature reduces error beyond seasonality.
# Positive conditional value means the full model gets worse without it.
gain = calendar_error.error - added.error
conditional = removed.error - full_error.error
by_period = gain.groupby(calendar_error.period).mean()
rows.append({
'Predictor': feature,
'Error reduction vs calendar (thousand b/d)': gain.mean(),
'Error reduction vs calendar (%)': 100 * gain.mean() / calendar_error.error.mean(),
'Periods helped (of 6)': int((by_period > 0).sum()),
'Error increase when removed (thousand b/d)': conditional.mean(),
'Periods hurt by removal (of 6)': int((conditional.groupby(full_error.period).mean() > 0).sum()),
# Retain fold-level gains to show whether the average is historically stable.
**{f'Period {i} gain': by_period.loc[i] for i in range(1, 7)},
})
# The main rank is based on individual improvement over calendar seasonality.
table = pd.DataFrame(rows).sort_values(
'Error reduction vs calendar (thousand b/d)', ascending=False
).reset_index(drop=True)
table.index = table.index + 1
table.index.name = 'Rank'
rankings[target_name] = table
# Treat all 12 calendar indicators as one conceptual predictor when measuring
# how much seasonality contributes alongside every noncalendar feature.
without_calendar = errors(y, features)
calendar_importance.append({
'Target': target_name,
'Predictor': 'Calendar month (all 12 month indicators)',
'Error increase when removed (thousand b/d)': (without_calendar.error - full_error.error).mean(),
})
Most important variables¶
The top three individual predictors for each target appear first.
# Build a compact table from the full rankings. Only predictors that improve on
# the calendar baseline are eligible, and at most three are shown for each target.
gain_column = 'Error reduction vs calendar (thousand b/d)'
conditional_column = 'Error increase when removed (thousand b/d)'
summary = []
for target_name, table in rankings.items():
positive = table[table[gain_column] > 0].head(3)
for rank, row in positive.iterrows():
summary.append({
'Target': target_name,
'Rank': rank,
'Predictor': row.Predictor,
'Error reduction (thousand b/d)': row[gain_column],
'Periods helped (of 6)': row['Periods helped (of 6)'],
# This flag distinguishes a strong stand-alone predictor from one that
# still contributes after correlated inputs enter the full model.
'Adds information alongside other inputs': bool(row[conditional_column] > 0),
})
display(pd.DataFrame(summary).round(2))
| Target | Rank | Predictor | Error reduction (thousand b/d) | Periods helped (of 6) | Adds information alongside other inputs | |
|---|---|---|---|---|---|---|
| 0 | Supply: production + imports | 1 | Supply (production + imports) — previous 3-month average | 2785.23 | 6 | True |
| 1 | Supply: production + imports | 2 | Supply (production + imports) — previous month | 2714.99 | 6 | True |
| 2 | Supply: production + imports | 3 | Crude production — previous 3-month average | 2689.13 | 6 | True |
| 3 | Demand: crude refinery inputs | 1 | Crude refinery inputs — previous 3-month average | 532.84 | 6 | True |
| 4 | Demand: crude refinery inputs | 2 | Crude refinery inputs — previous month | 518.87 | 6 | True |
| 5 | Demand: crude refinery inputs | 3 | Gasoline net production — previous month | 422.99 | 6 | True |
For crude supply, the previous three month average of production plus imports was most important.
For crude demand, the previous three month average of refinery inputs was most important.
Individual predictor rankings¶
Supply: production + imports¶
# Plot and print the individual rankings for supply and demand separately.
target_name = 'Supply: production + imports'
table = rankings[target_name]
# Reverse the top 12 so the highest-ranked predictor appears at the top of barh.
top = table.head(12).iloc[::-1]
fig, ax = plt.subplots(figsize=(13, 7))
# Green bars improve on calendar; red bars increase average error.
colors = ['#237a57' if value > 0 else '#b65a52' for value in top[gain_column]]
ax.barh(top.Predictor, top[gain_column], color=colors)
ax.axvline(0, color='black', linewidth=0.8)
ax.set_title(FUEL.title() + ' ' + target_name.lower() + ': strongest individual predictors')
ax.set_xlabel('Reduction in average absolute error vs calendar month (thousand barrels/day)')
ax.set_ylabel('')
fig.tight_layout()
plt.show()
# Period-level columns are shown later; omit them here to keep this table readable.
display(table.drop(columns=[f'Period {i} gain' for i in range(1, 7)]).round(2))
| Predictor | Error reduction vs calendar (thousand b/d) | Error reduction vs calendar (%) | Periods helped (of 6) | Error increase when removed (thousand b/d) | Periods hurt by removal (of 6) | |
|---|---|---|---|---|---|---|
| Rank | ||||||
| 1 | Supply (production + imports) — previous 3-month average | 2785.23 | 88.79 | 6 | 4.65 | 4 |
| 2 | Supply (production + imports) — previous month | 2714.99 | 86.55 | 6 | 1.17 | 3 |
| 3 | Crude production — previous 3-month average | 2689.13 | 85.72 | 6 | 0.41 | 2 |
| 4 | Crude production — previous month | 2680.78 | 85.46 | 6 | 0.51 | 5 |
| 5 | Crude exports — previous month | 2165.55 | 69.03 | 6 | -16.66 | 1 |
| 6 | Crude exports — previous 3-month average | 2123.85 | 67.70 | 6 | 6.87 | 3 |
| 7 | Crude imports — previous 3-month average | 1824.12 | 58.15 | 6 | -0.71 | 2 |
| 8 | Crude imports — previous month | 1750.54 | 55.80 | 6 | -1.12 | 4 |
| 9 | Transfers — previous 3-month average | 1654.87 | 52.75 | 4 | 3.71 | 3 |
| 10 | Transfers — previous month | 1617.65 | 51.57 | 4 | -6.69 | 2 |
| 11 | Crude refinery inputs — previous 3-month average | 1167.89 | 37.23 | 6 | 0.79 | 2 |
| 12 | Crude refinery inputs — previous month | 1066.39 | 33.99 | 6 | 2.85 | 6 |
| 13 | Crude distillation capacity — previous month | 1004.89 | 32.03 | 6 | 9.19 | 4 |
| 14 | Gasoline net production — previous month | 850.51 | 27.11 | 6 | 1.23 | 4 |
| 15 | Core balance (production + imports − exports − demand) — previous 3-month average | 828.17 | 26.40 | 6 | 13.71 | 4 |
| 16 | Inventory level — previous month | 803.74 | 25.62 | 6 | 1.41 | 4 |
| 17 | Crude refinery utilization — previous month | 758.10 | 24.17 | 6 | -0.27 | 3 |
| 18 | Core balance (production + imports − exports − demand) — previous month | 586.74 | 18.70 | 6 | 7.71 | 4 |
| 19 | Inventory level minus year-earlier level | 29.77 | 0.95 | 4 | 17.12 | 5 |
| 20 | Adjustments — previous 3-month average | 25.10 | 0.80 | 3 | -1.52 | 3 |
| 21 | Direct crude use — previous 3-month average | 0.00 | 0.00 | 0 | -0.00 | 2 |
| 22 | Direct crude use — previous month | 0.00 | 0.00 | 0 | -0.00 | 2 |
| 23 | Gasoline inventory change — previous month | -2.04 | -0.07 | 2 | 3.61 | 4 |
| 24 | Inventory change — previous month | -2.16 | -0.07 | 3 | -2.93 | 1 |
| 25 | Inventory change — previous 3-month average | -6.51 | -0.21 | 2 | -12.00 | 0 |
| 26 | Gasoline product supplied — previous month | -8.77 | -0.28 | 2 | -2.88 | 3 |
| 27 | Adjustments — previous month | -14.18 | -0.45 | 3 | 3.70 | 5 |
| 28 | Net regional receipts — previous month | -15.27 | -0.49 | 1 | 1.51 | 4 |
| 29 | Net regional receipts — previous 3-month average | -49.39 | -1.57 | 1 | 0.91 | 4 |
Demand: crude refinery inputs¶
target_name = 'Demand: crude refinery inputs'
table = rankings[target_name]
# Reverse the top 12 so the highest-ranked predictor appears at the top of barh.
top = table.head(12).iloc[::-1]
fig, ax = plt.subplots(figsize=(13, 7))
# Green bars improve on calendar; red bars increase average error.
colors = ['#237a57' if value > 0 else '#b65a52' for value in top[gain_column]]
ax.barh(top.Predictor, top[gain_column], color=colors)
ax.axvline(0, color='black', linewidth=0.8)
ax.set_title(FUEL.title() + ' ' + target_name.lower() + ': strongest individual predictors')
ax.set_xlabel('Reduction in average absolute error vs calendar month (thousand barrels/day)')
ax.set_ylabel('')
fig.tight_layout()
plt.show()
# Period-level columns are shown later; omit them here to keep this table readable.
display(table.drop(columns=[f'Period {i} gain' for i in range(1, 7)]).round(2))
| Predictor | Error reduction vs calendar (thousand b/d) | Error reduction vs calendar (%) | Periods helped (of 6) | Error increase when removed (thousand b/d) | Periods hurt by removal (of 6) | |
|---|---|---|---|---|---|---|
| Rank | ||||||
| 1 | Crude refinery inputs — previous 3-month average | 532.84 | 67.46 | 6 | 8.34 | 4 |
| 2 | Crude refinery inputs — previous month | 518.87 | 65.69 | 6 | 5.96 | 6 |
| 3 | Gasoline net production — previous month | 422.99 | 53.55 | 6 | 3.23 | 4 |
| 4 | Crude refinery utilization — previous month | 363.68 | 46.04 | 6 | 0.89 | 4 |
| 5 | Crude distillation capacity — previous month | 359.87 | 45.56 | 6 | 7.58 | 4 |
| 6 | Inventory level — previous month | 334.02 | 42.29 | 6 | 12.87 | 1 |
| 7 | Crude imports — previous month | 265.04 | 33.55 | 5 | -1.31 | 1 |
| 8 | Crude imports — previous 3-month average | 259.92 | 32.91 | 5 | -2.33 | 2 |
| 9 | Transfers — previous month | 172.88 | 21.89 | 4 | -7.27 | 4 |
| 10 | Transfers — previous 3-month average | 169.71 | 21.49 | 4 | 1.29 | 4 |
| 11 | Supply (production + imports) — previous month | 164.47 | 20.82 | 4 | -0.80 | 3 |
| 12 | Crude production — previous month | 148.69 | 18.82 | 4 | -0.11 | 4 |
| 13 | Crude production — previous 3-month average | 138.24 | 17.50 | 4 | -1.11 | 3 |
| 14 | Core balance (production + imports − exports − demand) — previous 3-month average | 131.37 | 16.63 | 5 | 8.16 | 5 |
| 15 | Supply (production + imports) — previous 3-month average | 126.27 | 15.99 | 4 | -1.99 | 4 |
| 16 | Core balance (production + imports − exports − demand) — previous month | 117.35 | 14.86 | 5 | -10.11 | 2 |
| 17 | Gasoline product supplied — previous month | 20.60 | 2.61 | 2 | -3.31 | 2 |
| 18 | Gasoline inventory change — previous month | 0.25 | 0.03 | 2 | 5.02 | 5 |
| 19 | Direct crude use — previous 3-month average | 0.00 | 0.00 | 0 | 0.00 | 4 |
| 20 | Direct crude use — previous month | 0.00 | 0.00 | 0 | 0.00 | 4 |
| 21 | Inventory change — previous month | -2.38 | -0.30 | 3 | -4.46 | 2 |
| 22 | Inventory change — previous 3-month average | -3.43 | -0.43 | 1 | -15.73 | 0 |
| 23 | Net regional receipts — previous month | -5.25 | -0.66 | 1 | 0.62 | 3 |
| 24 | Inventory level minus year-earlier level | -5.85 | -0.74 | 4 | 19.36 | 3 |
| 25 | Net regional receipts — previous 3-month average | -15.96 | -2.02 | 2 | -1.79 | 1 |
| 26 | Adjustments — previous 3-month average | -20.09 | -2.54 | 2 | -5.81 | 1 |
| 27 | Adjustments — previous month | -34.49 | -4.37 | 2 | -0.30 | 3 |
| 28 | Crude exports — previous month | -194.10 | -24.57 | 2 | -2.65 | 3 |
| 29 | Crude exports — previous 3-month average | -241.42 | -30.56 | 2 | -10.74 | 3 |
The three-month average of supply reduces error by about 2,785 thousand barrels/day versus calendar month alone.
For demand, the three-month average of refinery inputs reduces error by about 533 thousand barrels/day.
Which variables matter alongside the others?¶
These rankings remove one input at a time from a ridge model. Positive values mean that removing the input makes predictions worse. Negative values mean predictions improve without it.
# Reorder the same ranking tables by conditional importance: the change in
# full-model error when one predictor is removed and the model is refitted.
for target_name, table in rankings.items():
ordered = table.sort_values(conditional_column, ascending=False).head(12).iloc[::-1]
fig, ax = plt.subplots(figsize=(13, 7))
# Green means removal hurts predictions; red means removal improves them.
ax.barh(ordered.Predictor, ordered[conditional_column],
color=['#237a57' if v > 0 else '#b65a52' for v in ordered[conditional_column]])
ax.axvline(0, color='black', linewidth=0.8)
ax.set_title(target_name + ': which variables add information alongside all other inputs?')
ax.set_xlabel('Increase in average absolute error after removing and refitting (thousand barrels/day)')
fig.tight_layout()
plt.show()
# Calendar month is removed as a 12-column block, so report it separately from
# the one-column removal rankings plotted above.
display(pd.DataFrame(calendar_importance).round(2))
| Target | Predictor | Error increase when removed (thousand b/d) | |
|---|---|---|---|
| 0 | Supply: production + imports | Calendar month (all 12 month indicators) | 10.96 |
| 1 | Demand: crude refinery inputs | Calendar month (all 12 month indicators) | 70.65 |
The inventory level minus its year-earlier level adds the most information in the removal test for both supply and demand as removing it increases error by about 17 and 19 thousand barrels/day, respectively.
These variables are weak on their own as above they did not provide much error reduction but helps the model interpret the other inputs.
How consistent is each predictor?¶
Supply: production + imports¶
# Show the top ten individual predictors' gains within each validation block.
# This reveals whether a high overall average is broad or driven by one period.
target_name = 'Supply: production + imports'
table = rankings[target_name]
columns = [f'Period {i} gain' for i in range(1, 7)]
display(table.head(10).set_index('Predictor')[columns].round(2))
| Period 1 gain | Period 2 gain | Period 3 gain | Period 4 gain | Period 5 gain | Period 6 gain | |
|---|---|---|---|---|---|---|
| Predictor | ||||||
| Supply (production + imports) — previous 3-month average | 2948.14 | 2770.35 | 2296.66 | 2963.14 | 2867.23 | 2865.89 |
| Supply (production + imports) — previous month | 2832.89 | 2658.28 | 2315.81 | 2824.48 | 2840.18 | 2818.30 |
| Crude production — previous 3-month average | 2259.66 | 2765.29 | 2447.02 | 2885.96 | 2886.88 | 2889.98 |
| Crude production — previous month | 2280.37 | 2775.27 | 2429.86 | 2850.80 | 2868.68 | 2879.71 |
| Crude exports — previous month | 2269.84 | 1646.04 | 947.51 | 2845.58 | 2801.79 | 2482.57 |
| Crude exports — previous 3-month average | 2013.54 | 1499.73 | 823.63 | 2899.20 | 2867.48 | 2639.53 |
| Crude imports — previous 3-month average | 639.94 | 1837.42 | 2196.10 | 1915.91 | 2045.95 | 2309.38 |
| Crude imports — previous month | 637.45 | 1731.04 | 2133.58 | 1763.34 | 2022.97 | 2214.84 |
| Transfers — previous 3-month average | 0.00 | 0.00 | 1787.08 | 2824.23 | 2847.98 | 2469.95 |
| Transfers — previous month | 0.00 | 0.00 | 1967.93 | 2890.00 | 2717.39 | 2130.61 |
Demand: crude refinery inputs¶
target_name = 'Demand: crude refinery inputs'
table = rankings[target_name]
columns = [f'Period {i} gain' for i in range(1, 7)]
display(table.head(10).set_index('Predictor')[columns].round(2))
| Period 1 gain | Period 2 gain | Period 3 gain | Period 4 gain | Period 5 gain | Period 6 gain | |
|---|---|---|---|---|---|---|
| Predictor | ||||||
| Crude refinery inputs — previous 3-month average | 1180.18 | 723.90 | 100.87 | 162.37 | 435.21 | 594.49 |
| Crude refinery inputs — previous month | 1149.94 | 693.03 | 137.86 | 133.80 | 451.07 | 547.53 |
| Gasoline net production — previous month | 990.77 | 692.43 | 155.77 | 117.26 | 289.92 | 291.78 |
| Crude refinery utilization — previous month | 807.61 | 276.94 | 92.14 | 63.61 | 388.54 | 553.25 |
| Crude distillation capacity — previous month | 987.72 | 463.80 | 47.34 | 223.92 | 359.60 | 76.84 |
| Inventory level — previous month | 532.47 | 607.69 | 24.76 | 240.23 | 310.27 | 288.70 |
| Crude imports — previous month | 450.37 | 509.41 | -473.32 | 55.88 | 403.96 | 643.96 |
| Crude imports — previous 3-month average | 487.37 | 505.49 | -583.87 | 63.74 | 458.07 | 628.70 |
| Transfers — previous month | 0.00 | 0.00 | 119.15 | 195.06 | 345.31 | 377.76 |
| Transfers — previous 3-month average | 0.00 | 0.00 | 123.93 | 171.49 | 354.78 | 368.08 |
# Print the exact scored dates and the amount of earlier training data available
# for each block so the expanding-window design can be audited from the notebook.
display(periods)
| Period | First scored month | Last scored month | Scored months | Earlier training months | |
|---|---|---|---|---|---|
| 0 | 1 | 2018-05 | 2019-04 | 12 | 123 |
| 1 | 2 | 2019-05 | 2022-06 | 12 | 135 |
| 2 | 3 | 2022-07 | 2023-06 | 12 | 147 |
| 3 | 4 | 2023-07 | 2024-06 | 12 | 159 |
| 4 | 5 | 2024-07 | 2025-06 | 12 | 171 |
| 5 | 6 | 2025-07 | 2026-06 | 12 | 183 |
Recent supply, crude production, exports and imports help predict supply in all six periods when tested individually.
For demand, recent refinery inputs, gasoline net production, refinery utilization and distillation capacity also help in all six periods.
The additional value of a variable is less uniform once all inputs are present. For example, the year-over-year inventory difference helps the full supply model in five periods, but the full demand model in only three.
Conclusion¶
For crude supply, recent production and supply levels are the strongest individual predictors. For crude demand, recent refinery inputs are strongest, with gasoline production and refinery operating measures as other strong predictors. The three-month averages of supply and refinery inputs are stronger than their previous-month values.
Inventory information is generally less important on its own, but the year-over-year inventory difference adds information alongside the other variables. (This is kind of a proxy for seasonality)
Comparing crude and gasoline¶
Gasoline is easier to predict compared to crude based on these tests as its errors are smaller both in barrels/day and relative to the size of supply or demand.
The strongest predictors are similar, but the variables that add information alongside the other inputs differ.
Table below shows average absolute error by the average target level to account for crude volumes being larger than gasoline volumes.
| Target | Full-model error, thousand barrels/day | Error as % of average target | Best individual predictor + calendar: error % |
|---|---|---|---|
| Crude supply | 354 | 1.82% | 1.81% |
| Gasoline supply | 138 | 1.40% | 1.63% |
| Crude demand | 338 | 2.07% | 1.57% |
| Gasoline demand | 110 | 1.22% | 1.20% |
Gasoline has lower relative errors for both targets, even when using just the strongest individual predictor plus calendar effects.
However, crude improves more relative to the baseline of only using the month of the year. Its full model reduces supply error by 89%, compared with 60% for gasoline. For demand, the reductions are 57% and 46%. This means crude has larger error reductions.
Do the important predictors differ?¶
The strongest individual predictor are the same.
| Target | Crude | Gasoline |
|---|---|---|
| Supply | Previous three-month average of production + imports | Previous three-month average of production + imports |
| Demand | Previous three-month average of refinery inputs | Previous three-month average of product supplied |
Second most important predictors were:
Crude supply: recent crude production
Crude demand: gasoline production
Gasoline supply: crude distillation capacity
Gasoline demand: recent product supplied
| Target | Largest error increase when a variable is removed |
|---|---|
| Crude supply | Crude year-over-year inventory difference: 17.1 thousand b/d |
| Crude demand | Crude year-over-year inventory difference: 19.4 thousand b/d |
| Gasoline supply | Previous-month crude inventory change: 7.6 thousand b/d |
| Gasoline demand | Three-month average gasoline product supplied: 9.2 thousand b/d |
Previous-month crude inventory change also helps the full gasoline demand model: removing it increases error by 6.0 thousand b/d.