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))
No description has been provided for this image
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))
No description has been provided for this image
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))
No description has been provided for this image
No description has been provided for this image
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.