Skip to the analysis
GARETH GRANTTECHNOLOGY · DATA · INNOVATION Back to projects
Back to all projects

Portfolio case study · Regression · Python

Vehicle CO₂ emissions.From ratings to decisions.

A transparent, reproducible workflow connecting vehicle data to business, audit and policy decisions.

Gareth Grant · 4 October 2026 · Executed code, charts and interpretation

7,385vehicle records
16guided sections
149focused code cells
3.33 g/kmselected-model RMSE

Evidence note: Fuel-interaction Ridge was selected. Held-out R² was 0.9969; the specifications-only model had an RMSE of 23.54 g/km. Company and country scenarios are clearly identified as assumptions.

Table of contents
GARETH GRANTPYTHON · DATA ANALYSIS · BUSINESS JUDGEMENT
Explore the 16 sections and references

Vehicle CO₂ emissions: learn the code, explain the results, use the evidence

Gareth Grant · Python data-analysis portfolio · Verified run: 4 October 2026

This notebook turns Canadian vehicle ratings into an understandable analysis and a set of business, audit and policy demonstrations. Every numbered step has one purpose, a short code cell with comments, and, where useful, a separate interpretation cell. Interpretation outputs update when the calculation changes. Edit the Markdown explanations and the text passed to explain() to add your own comments.

The supplied CSV was used for the executed outputs. Numerical model results are measured on held-out Make–Model groups. Company fleets, sales volumes, prices and tax scenarios are explicitly labelled assumptions. The tax sources were checked on 4 October 2026.

Read in three passes: follow Sections 1–9 to learn the analysis; use Sections 10–12 for business and audit applications; finish with Section 13 for national sales scenarios and limitations.

Executive result

The supplied CSV contains 7,385 vehicle-rating records, with 1,103 exact repeated rows and no missing values.

Measured result: Fuel-interaction Ridge achieved RMSE 3.33 g/km, MAE 2.23 g/km and R² 0.9969 on 1,466 held-out records from 410 exact Make–Model groups. The selected specifications-only model had RMSE 23.54 g/km. Fuel consumption and fuel type make prediction much easier because of their physical connection to CO₂.

Decision value: the later sections demonstrate fleet budgets, jurisdiction-specific levy arithmetic, traceable audit screening and sales-weighted national scenarios. Company activity and sales volumes are assumptions; the CSV alone cannot establish actual tax liability or a national emissions total.

1. Start with decisions, not an algorithm

Reader Decision supported Evidence needed beyond this CSV
Manufacturer or importer Compare prospective product mixes and levy exposure Applicable certificates, tax classification, effective date and unit sales
Fleet manager Compare rated fuel costs and tailpipe emissions Actual mileage, fuel prices, routes, purchase cost and operating requirements
Auditor Identify records or calculations requiring evidence VIN/variant identifiers, certificates, invoices, ledger totals and a documented materiality policy
National policymaker Explore the emissions effect of changes in new vehicle sales Registrations/sales weights, vehicle-kilometres, fleet age, retirements and fuel statistics

These are potential uses inferred from the analysis, not outcomes tested on a business, audit engagement or national registry. Where a valid measured rating is available, use it rather than replacing it with a model estimate.

The question

Can we estimate a vehicle's reported tailpipe CO₂ rating, and explain how that rating can inform decisions? We examine two information settings: specifications only, and fuel consumption already known. Those settings solve different practical problems.

The unit

The target is g/km: grams of CO₂ per kilometre. It is an emissions intensity, not annual tonnes. An annual estimate needs a distance. It is also different from atmospheric CO₂ concentration (ppm), all greenhouse gases (CO₂e), and lifecycle emissions.

2. Understand linear regression first

For one predictor, a fitted model has the form:

\widehat{CO_2}=b_0+b_1\times\text{engine size}.

With two predictors:

\widehat{CO_2}=b_0+b_1\times\text{engine size}+b_2\times\text{cylinders}.

Ordinary least squares chooses coefficients to minimise the sum of squared residuals. A residual here is actual − predicted. Coefficients describe the fitted association, not an independent causal effect. Engine size and cylinders are strongly related, so their separate coefficients require cautious interpretation.

Ridge adds a penalty on coefficient size. Random forests fit multiple decision trees. We compare them with simple alternatives; a more complex model must earn its place through validation.

What changed from the supplied v3 notebook?

The v3 notebook provides more explanations than the first upload, but it fits X and y after creating a split, then evaluates predictions on the same full dataset. Its RMSE ≈29.96 and R² ≈0.738 are in-sample results. Its fitted best-fit line is also described as an ideal 1:1 line, and one example uses g/kg instead of g/km. This edition fits training partitions only, reports held-out performance, and uses a true identity line for the prediction plot.

3. Set up a reproducible notebook

Place CO2 Emissions_Canada.csv beside the notebook. In Jupyter, choose Restart Kernel and Run All Cells. The fallback upload/ location is useful when working with the attached files. The package includes requirements.txt with the analysis versions used for this run.

Imports are split by role. Shared helpers only format outputs or construct reusable pipelines; the individual analysis steps remain visible below.

Step 1. Import data-handling tools

These tools load tables, create arrays and record the input fingerprint.

Read / edit Pythoncell 1
from pathlib import Path
import hashlib
import json
import platform
import numpy as np
import pandas as pd

Step 2. Import plotting tools

Matplotlib draws figures; seaborn provides readable statistical plots.

Read / edit Pythoncell 2
import matplotlib
import matplotlib.pyplot as plt
import seaborn as sns

Step 3. Import model tools

Each estimator below will later be created in its own cell.

Read / edit Pythoncell 3
import sklearn
from sklearn.base import clone
from sklearn.dummy import DummyRegressor
from sklearn.linear_model import LinearRegression, Ridge
from sklearn.ensemble import RandomForestRegressor

Step 4. Import preprocessing tools

Pipelines fit these transformations inside the appropriate training partition.

Read / edit Pythoncell 4
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder, FunctionTransformer

Step 5. Import validation tools

Grouped splitting keeps records of one exact vehicle model together.

Read / edit Pythoncell 5
from sklearn.model_selection import GroupShuffleSplit, GroupKFold
from sklearn.model_selection import cross_validate, cross_val_predict
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score

Step 6. Choose the input file

Only the supplied CSV is used. No replacement dataset is downloaded.

Read / edit Pythoncell 6
# Read from the notebook folder, or the attachments folder.
DATA_PATH = Path('CO2 Emissions_Canada.csv')
if not DATA_PATH.is_file():
    DATA_PATH = Path('upload') / 'CO2 Emissions_Canada.csv'
if not DATA_PATH.is_file():
    raise FileNotFoundError('Place the original CSV beside this notebook.')

Step 7. Record provenance

The exact source link comes from the supplied v3 notebook. Its licence was not verified.

Read / edit Pythoncell 7
DATASET_URL = 'https://www.kaggle.com/datasets/karomatovdovudkhon/co2-emissions-canada'
SOURCE_ACCESS_DATE = '1 April 2024, as cited in the supplied v3 notebook'
SOURCE_LICENCE = 'Not independently verified'
SOURCE_CHECKED_DATE = '2026-10-04'
SEED = 42
OUTPUT_DIR = Path('co2_outputs')
SAVE_OUTPUTS = False  # Change to True to export local charts and result tables.

Step 8. Define the analysis settings

The test share is a share of model groups, not an exact share of rows.

Read / edit Pythoncell 8
TEST_GROUP_SHARE = 0.20
CV_FOLDS = 5
N_BOOTSTRAPS = 1000
FUEL_CODES = ['X', 'Z', 'D', 'E', 'N']
FUEL_NAMES = {'X': 'Regular gasoline', 'Z': 'Premium gasoline',
              'D': 'Diesel', 'E': 'Ethanol (E85)', 'N': 'Natural gas'}

Step 9. Set readable chart colours

The palette matches the portfolio; chart colours still retain a clear legend.

Read / edit Pythoncell 9
NAVY, SCARLET, SLATE, SILVER = '#16324F', '#FF2400', '#667582', '#B8C2CC'
FUEL_PALETTE = {'X': NAVY, 'Z': '#587C9B', 'D': '#20252B',
                'E': SCARLET, 'N': '#967143'}
sns.set_theme(style='whitegrid', context='notebook')
plt.rcParams.update({'figure.dpi': 110, 'savefig.dpi': 220,
                     'axes.spines.top': False, 'axes.spines.right': False})

Step 10. Create the interpretation helper

Jupyter can display Markdown. This helper keeps interpretation cells short and editable.

Read / edit Pythoncell 10
def explain(message):
    # Render a result paragraph using Jupyter's built-in display function.
    display({'text/markdown': message}, raw=True)

Step 11. Create the figure helper

One helper handles layout and optional export; each chart gets its own analysis cell.

Read / edit Pythoncell 11
def show_figure(fig, name):
    fig.tight_layout()
    if SAVE_OUTPUTS:
        OUTPUT_DIR.mkdir(exist_ok=True)
        fig.savefig(OUTPUT_DIR / f'{name}.png', bbox_inches='tight')
        fig.savefig(OUTPUT_DIR / f'{name}.svg', bbox_inches='tight')
    plt.show()

4. Inspect the data before making claims

Rows are catalogue/rating entries. They are not vehicles sold, and their frequency is not market share. The file does not include a model year, a sales quantity, annual distance, VIN, or tax certificate identifier. Do not assume that these entries represent all vehicles on a country's roads.

The source names fuel consumption in L/100 km and mpg. We use L/100 km consistently and avoid treating an inverse unit conversion as an independent physical measurement.

Step 12. Load the raw CSV

Keep a raw copy so every later transformation can be traced.

Read / edit Pythoncell 12
raw = pd.read_csv(DATA_PATH)
source_sha256 = hashlib.sha256(DATA_PATH.read_bytes()).hexdigest()
print('Input fingerprint:', source_sha256)
Input fingerprint: e832d820e42066d0946b02a58bce8bc1c7da9684f5724348ade9753b8eabec72

Step 13. Count rows and columns

Start by explaining what one row represents.

Read / edit Pythoncell 13
n_rows, n_columns = raw.shape
display(pd.Series({'Catalogue records': n_rows, 'Variables': n_columns}).to_frame('Count'))
Count
Catalogue records 7385
Variables 12
Edit the interpretationcell 14
explain(f"""**Interpretation:** There are **{n_rows:,} catalogue records** and **{n_columns} variables**.
The row count is not a count of Canadian vehicle sales.""")

Interpretation: There are 7,385 catalogue records and 12 variables. The row count is not a count of Canadian vehicle sales.

Step 14. Preview five records

A small preview is more readable on a website than printing the entire dataset.

Read / edit Pythoncell 15
display(raw.head(5))
Make Model Vehicle Class Engine Size(L) Cylinders Transmission Fuel Type Fuel Consumption City (L/100 km) Fuel Consumption Hwy (L/100 km) Fuel Consumption Comb (L/100 km) Fuel Consumption Comb (mpg) CO2 Emissions(g/km)
0 ACURA ILX COMPACT 2.0 4 AS5 Z 9.9 6.7 8.5 33 196
1 ACURA ILX COMPACT 2.4 4 M6 Z 11.2 7.7 9.6 29 221
2 ACURA ILX HYBRID COMPACT 1.5 4 AV7 Z 6.0 5.8 5.9 48 136
3 ACURA MDX 4WD SUV - SMALL 3.5 6 AS6 Z 12.7 9.1 11.1 25 255
4 ACURA RDX AWD SUV - SMALL 3.5 6 AS6 Z 12.1 8.7 10.6 27 244

Step 15. Check data types

Numeric columns should be numeric before correlations and model fitting.

Read / edit Pythoncell 16
display(raw.dtypes.astype(str).to_frame('Data type'))
Data type
Make object
Model object
Vehicle Class object
Engine Size(L) float64
Cylinders int64
Transmission object
Fuel Type object
Fuel Consumption City (L/100 km) float64
Fuel Consumption Hwy (L/100 km) float64
Fuel Consumption Comb (L/100 km) float64
Fuel Consumption Comb (mpg) int64
CO2 Emissions(g/km) int64

Step 16. Check missing values once

isna() and isnull() are aliases; repeating both adds no new evidence.

Read / edit Pythoncell 17
missing_counts = raw.isna().sum()
display(missing_counts.to_frame('Missing values'))
Missing values
Make 0
Model 0
Vehicle Class 0
Engine Size(L) 0
Cylinders 0
Transmission 0
Fuel Type 0
Fuel Consumption City (L/100 km) 0
Fuel Consumption Hwy (L/100 km) 0
Fuel Consumption Comb (L/100 km) 0
Fuel Consumption Comb (mpg) 0
CO2 Emissions(g/km) 0
Edit the interpretationcell 18
explain(f"""**Interpretation:** The supplied file has **{int(missing_counts.sum()):,} missing cells**.
This does not prove that the remaining values are correct or unique.""")

Interpretation: The supplied file has 0 missing cells. This does not prove that the remaining values are correct or unique.

Step 17. Count exact repeated rows

Repeated rows can influence both descriptive summaries and model fitting.

Read / edit Pythoncell 19
raw_repeated_rows = int(raw.duplicated().sum())
print('Full-row repeats after the first occurrence:', raw_repeated_rows)
Full-row repeats after the first occurrence: 1103
Edit the interpretationcell 20
explain(f"""**Interpretation:** There are **{raw_repeated_rows:,} exact repeated rows**.
Without model years, some repeats could be legitimate historical ratings. We retain them for
the main run, keep model identities together across splits, and later test removal as a sensitivity analysis.""")

Interpretation: There are 1,103 exact repeated rows. Without model years, some repeats could be legitimate historical ratings. We retain them for the main run, keep model identities together across splits, and later test removal as a sensitivity analysis.

Step 18. Define shorter column names

The source names and units remain documented; shorter names make learning the code easier.

Read / edit Pythoncell 21
rename_columns = {
    'Make': 'make', 'Model': 'model', 'Vehicle Class': 'vehicle_class',
    'Engine Size(L)': 'engine_l', 'Cylinders': 'cylinders',
    'Transmission': 'transmission', 'Fuel Type': 'fuel_type',
    'Fuel Consumption City (L/100 km)': 'city_l_100km',
    'Fuel Consumption Hwy (L/100 km)': 'hwy_l_100km',
    'Fuel Consumption Comb (L/100 km)': 'combined_l_100km',
    'Fuel Consumption Comb (mpg)': 'combined_mpg',
    'CO2 Emissions(g/km)': 'co2_g_km',
}

Step 19. Check the expected schema

Stop if a different CSV has been supplied instead of silently using the wrong columns.

Read / edit Pythoncell 22
missing_columns = sorted(set(rename_columns) - set(raw.columns))
if missing_columns:
    raise ValueError(f'Missing columns: {missing_columns}')
data = raw.rename(columns=rename_columns).copy()

Step 20. Trim text without discarding records

Leading or trailing spaces can create false differences between model identities.

Read / edit Pythoncell 23
text_columns = ['make', 'model', 'vehicle_class', 'transmission', 'fuel_type']
for column in text_columns:
    data[column] = data[column].str.strip()
normalised_repeats = int(data.duplicated().sum())
data['source_row'] = np.arange(len(data))  # Zero-based CSV row index, excluding the header.
Edit the interpretationcell 24
explain(f"""**Interpretation:** After trimming text, **{normalised_repeats:,} rows** repeat a full normalised record.
We preserve the zero-based source row to locate a record in the original file.""")

Interpretation: After trimming text, 1,104 rows repeat a full normalised record. We preserve the zero-based source row to locate a record in the original file.

Step 21. Check numeric validity

Negative or zero combustion-vehicle inputs require investigation before fitting.

Read / edit Pythoncell 25
numeric_columns = ['engine_l', 'cylinders', 'city_l_100km', 'hwy_l_100km',
                   'combined_l_100km', 'combined_mpg', 'co2_g_km']
domain_checks = pd.DataFrame({'Non-finite': (~np.isfinite(data[numeric_columns])).sum(),
                              'Non-positive': data[numeric_columns].le(0).sum()})
display(domain_checks)
assert np.isfinite(data[numeric_columns]).all().all()
assert data[numeric_columns].gt(0).all().all()
assert data['cylinders'].mod(1).eq(0).all()
Non-finite Non-positive
engine_l 0 0
cylinders 0 0
city_l_100km 0 0
hwy_l_100km 0 0
combined_l_100km 0 0
combined_mpg 0 0
co2_g_km 0 0
Edit the interpretationcell 26
explain(f"""**Interpretation:** This CSV passes the positive-value, finite-number and integer-cylinder checks.
We do not remove a large engine or high-emission record solely because it is unusual.""")

Interpretation: This CSV passes the positive-value, finite-number and integer-cylinder checks. We do not remove a large engine or high-emission record solely because it is unusual.

Step 22. Count fuel categories

Sample size matters when interpreting averages or model reliability.

Read / edit Pythoncell 27
fuel_counts = data['fuel_type'].value_counts().reindex(FUEL_CODES, fill_value=0)
fuel_count_table = fuel_counts.to_frame('Records')
fuel_count_table['Meaning'] = fuel_count_table.index.map(FUEL_NAMES)
display(fuel_count_table)
Records Meaning
fuel_type
X 3637 Regular gasoline
Z 3202 Premium gasoline
D 175 Diesel
E 370 Ethanol (E85)
N 1 Natural gas
Edit the interpretationcell 28
explain(f"""**Interpretation:** Natural gas has **{int(fuel_counts['N'])} record**; E85 has **{int(fuel_counts['E']):,}**;
diesel has **{int(fuel_counts['D']):,}**. A result for a sparse category should not be presented
as established performance for an entire fuel market.""")

Interpretation: Natural gas has 1 record; E85 has 370; diesel has 175. A result for a sparse category should not be presented as established performance for an entire fuel market.

Step 23. Check the combined-rating arithmetic

The documented combined formula is 55% city and 45% highway.

Read / edit Pythoncell 29
expected_combined = 0.55 * data['city_l_100km'] + 0.45 * data['hwy_l_100km']
data['combined_gap_l_100km'] = data['combined_l_100km'] - expected_combined
RATING_TOLERANCE = 0.15  # A declared screening tolerance for rounded ratings.
data['combined_rating_review'] = data['combined_gap_l_100km'].abs() > RATING_TOLERANCE + 1e-8
print('Records above the screening tolerance:', int(data['combined_rating_review'].sum()))
Records above the screening tolerance: 45
Edit the interpretationcell 30
explain(f"""**Interpretation:** **{int(data['combined_rating_review'].sum()):,} records** differ from the weighted
city/highway calculation by more than {RATING_TOLERANCE:.2f} L/100 km. This is a review flag,
not a finding of error or wrongdoing; source methods and rounding need checking.""")

Interpretation: 45 records differ from the weighted city/highway calculation by more than 0.15 L/100 km. This is a review flag, not a finding of error or wrongdoing; source methods and rounding need checking.

Step 24. Summarise the target

Use the distribution to give later prediction errors a meaningful scale.

Read / edit Pythoncell 31
target_summary = data['co2_g_km'].describe()
display(target_summary.round(2).to_frame('CO₂ (g/km)'))
CO₂ (g/km)
count 7385.00
mean 250.58
std 58.51
min 96.00
25% 208.00
50% 246.00
75% 288.00
max 522.00
Edit the interpretationcell 32
explain(f"""**Interpretation:** The catalogue mean is **{target_summary['mean']:.2f} g/km**, the median is
**{target_summary['50%']:.0f} g/km**, and the range is **{target_summary['min']:.0f}–{target_summary['max']:.0f} g/km**.
This is an unweighted mean of recorded entries, not a sales-weighted national mean.""")

Interpretation: The catalogue mean is 250.58 g/km, the median is 246 g/km, and the range is 96–522 g/km. This is an unweighted mean of recorded entries, not a sales-weighted national mean.

5. Protect the test set

An ordinary random row split may put repeated model entries on both sides. We instead hold out complete combinations of Make + exact Model text. This is an imperfect but useful separation: related trims and model families can still have different names. It does not simulate the next year of sales, because a verified year is unavailable.

The following cells split the data before model exploration. All exploratory plots use training records. The main selection uses grouped cross-validation inside that training partition.

Step 25. Create the grouping key

The two-column identity avoids collisions caused by simple string concatenation.

Read / edit Pythoncell 33
group_ids, group_names = pd.factorize(
    pd.MultiIndex.from_frame(data[['make', 'model']]), sort=True)
data['group_id'] = group_ids
print('Normalised Make–Model groups:', data['group_id'].nunique())
Normalised Make–Model groups: 2048

Step 26. Create a grouped hold-out split

A fixed seed makes the same group selection reproducible.

Read / edit Pythoncell 34
splitter = GroupShuffleSplit(n_splits=1, test_size=TEST_GROUP_SHARE, random_state=SEED)
train_indices, test_indices = next(splitter.split(data, groups=data['group_id']))

Step 27. Store training and test records

Use copies so later plotting or audit columns do not alter the input unexpectedly.

Read / edit Pythoncell 35
train = data.iloc[train_indices].copy()
test = data.iloc[test_indices].copy()

Step 28. Verify the separation

A useful check tests the intended boundary directly.

Read / edit Pythoncell 36
assert set(train['group_id']).isdisjoint(set(test['group_id']))
split_summary = pd.DataFrame({
    'Partition': ['Training', 'Held-out test'],
    'Records': [len(train), len(test)],
    'Groups': [train['group_id'].nunique(), test['group_id'].nunique()],
})
display(split_summary)
Partition Records Groups
0 Training 5919 1638
1 Held-out test 1466 410
Edit the interpretationcell 37
explain(f"""**Interpretation:** Training contains **{len(train):,} records** from **{train['group_id'].nunique():,} groups**.
The held-out test has **{len(test):,} records** from **{test['group_id'].nunique():,} groups**.
No exact Make–Model identity appears in both partitions.""")

Interpretation: Training contains 5,919 records from 1,638 groups. The held-out test has 1,466 records from 410 groups. No exact Make–Model identity appears in both partitions.

Step 29. Inspect fuel coverage across partitions

A model cannot claim validated category performance when the category is absent from testing.

Read / edit Pythoncell 38
fuel_coverage = pd.DataFrame({
    'Training': train['fuel_type'].value_counts(),
    'Test': test['fuel_type'].value_counts(),
}).fillna(0).astype(int).reindex(FUEL_CODES, fill_value=0)
display(fuel_coverage)
Training Test
fuel_type
X 2907 730
Z 2561 641
D 130 45
E 320 50
N 1 0
Edit the interpretationcell 39
explain(f"""**Interpretation:** Natural gas has **{fuel_coverage.loc['N', 'Training']} training record(s)** and
**{fuel_coverage.loc['N', 'Test']} test record(s)**. The main score does not establish natural-gas prediction quality.""")

Interpretation: Natural gas has 1 training record(s) and 0 test record(s). The main score does not establish natural-gas prediction quality.

6. Explore one relationship at a time

A useful chart answers a question. We use targeted plots rather than a large, difficult-to-read pair plot. Association is not proof of causation, and a visual pattern is not a statistical test.

Step 30. Plot the distribution of training emissions

This shows where most ratings lie and whether a few large values extend the range.

Read / edit Pythoncell 40
fig, ax = plt.subplots(figsize=(8, 4.6))
sns.histplot(data=train, x='co2_g_km', bins=30, color=NAVY, ax=ax)
ax.axvline(train['co2_g_km'].median(), color=SCARLET, linestyle='--', label='Median')
ax.set(title='Tailpipe CO₂ ratings — training records',
       xlabel='CO₂ (g/km)', ylabel='Catalogue records')
ax.legend()
show_figure(fig, '01_training_distribution')
Chart generated by the preceding code cell

Step 31. Calculate a correlation matrix

Pearson r measures the strength and direction of a linear association.

Read / edit Pythoncell 41
corr_columns = ['engine_l', 'cylinders', 'combined_l_100km', 'combined_mpg', 'co2_g_km']
correlations = train[corr_columns].corr()
display(correlations.round(3))
engine_l cylinders combined_l_100km combined_mpg co2_g_km
engine_l 1.000 0.928 0.813 -0.751 0.846
cylinders 0.928 1.000 0.777 -0.712 0.828
combined_l_100km 0.813 0.777 1.000 -0.925 0.914
combined_mpg -0.751 -0.712 -0.925 1.000 -0.905
co2_g_km 0.846 0.828 0.914 -0.905 1.000

Step 32. Plot a readable heatmap

Red is negative and blue is positive. A large negative correlation is still strong.

Read / edit Pythoncell 42
heatmap_labels = ['Engine (L)', 'Cylinders', 'Fuel (L/100 km)', 'Fuel (mpg)', 'CO₂ (g/km)']
fig, ax = plt.subplots(figsize=(7.2, 5.3))
sns.heatmap(correlations, annot=True, fmt='.2f', cmap='RdBu', vmin=-1, vmax=1,
            center=0, xticklabels=heatmap_labels, yticklabels=heatmap_labels,
            cbar_kws={'label': 'Pearson r'}, ax=ax)
ax.set_title('Linear associations — training records')
ax.tick_params(axis='x', rotation=30)
show_figure(fig, '02_training_correlations')
Chart generated by the preceding code cell
Edit the interpretationcell 43
explain(f"""**Interpretation:** Engine size and cylinders have **r = {correlations.loc['engine_l', 'cylinders']:.2f}**.
Combined fuel consumption and CO₂ have **r = {correlations.loc['combined_l_100km', 'co2_g_km']:.2f}**.
The inverse-unit mpg measure is related to the same fuel-use quantity; we exclude it from model inputs.""")

Interpretation: Engine size and cylinders have r = 0.93. Combined fuel consumption and CO₂ have r = 0.91. The inverse-unit mpg measure is related to the same fuel-use quantity; we exclude it from model inputs.

Step 33. Plot engine size against emissions

This is the relationship used by the simplest regression model.

Read / edit Pythoncell 44
fig, ax = plt.subplots(figsize=(8, 4.8))
ax.scatter(train['engine_l'], train['co2_g_km'], s=14, alpha=0.24, color=NAVY)
ax.set(title='Engine size and reported CO₂ — training records',
       xlabel='Engine size (L)', ylabel='CO₂ (g/km)')
show_figure(fig, '03_engine_and_emissions')
Chart generated by the preceding code cell
Edit the interpretationcell 45
explain(f"""**Interpretation:** Larger engines tend to have higher recorded emissions, but there is a wide
spread at the same engine size. Engine size alone will not identify an exact certified rating.""")

Interpretation: Larger engines tend to have higher recorded emissions, but there is a wide spread at the same engine size. Engine size alone will not identify an exact certified rating.

Step 34. Plot fuel consumption by fuel type

Separate colours reveal whether a common fuel-consumption slope misses structure.

Read / edit Pythoncell 46
fig, ax = plt.subplots(figsize=(8.5, 5.0))
sns.scatterplot(data=train, x='combined_l_100km', y='co2_g_km', hue='fuel_type',
                hue_order=FUEL_CODES, palette=FUEL_PALETTE, s=20, alpha=0.33,
                linewidth=0, ax=ax)
ax.set(title='Fuel consumption and CO₂ by fuel code — training records',
       xlabel='Combined consumption (L/100 km)', ylabel='CO₂ (g/km)')
ax.legend(title='Fuel: X/Z gasoline, D diesel, E E85, N natural gas')
show_figure(fig, '04_fuel_relationship')
Chart generated by the preceding code cell
Edit the interpretationcell 47
explain(f"""**Interpretation:** The points form fuel-dependent bands. Different fuels need not have the
same emissions per litre. This motivates testing fuel-specific slopes. X and Z remain separate;
their observational comparison does not isolate an octane effect.""")

Interpretation: The points form fuel-dependent bands. Different fuels need not have the same emissions per litre. This motivates testing fuel-specific slopes. X and Z remain separate; their observational comparison does not isolate an octane effect.

Step 35. Summarise vehicle classes in training

Class means describe this catalogue; show sample counts alongside them.

Read / edit Pythoncell 48
training_classes = train.groupby('vehicle_class').agg(
    records=('co2_g_km', 'size'), mean_co2_g_km=('co2_g_km', 'mean'),
    median_co2_g_km=('co2_g_km', 'median'))
display(training_classes.sort_values('records', ascending=False).round(2))
records mean_co2_g_km median_co2_g_km
vehicle_class
SUV - SMALL 994 235.06 232.0
MID-SIZE 883 220.62 212.0
COMPACT 827 218.27 207.0
SUV - STANDARD 602 303.15 305.0
FULL-SIZE 533 259.73 259.0
SUBCOMPACT 453 247.79 242.0
PICKUP TRUCK - STANDARD 410 303.20 299.0
TWO-SEATER 357 280.24 264.0
MINICOMPACT 288 235.67 242.0
STATION WAGON - SMALL 184 198.92 198.0
PICKUP TRUCK - SMALL 159 278.97 274.0
MINIVAN 65 261.68 262.0
SPECIAL PURPOSE VEHICLE 58 232.86 232.0
STATION WAGON - MID-SIZE 43 245.88 230.0
VAN - PASSENGER 43 395.70 414.0
VAN - CARGO 20 361.45 362.0

Step 36. Plot the most frequent classes

Boxplots show the spread within a class, which a mean alone would hide.

Read / edit Pythoncell 49
most_frequent_classes = training_classes.nlargest(8, 'records').index
fig, ax = plt.subplots(figsize=(9.2, 5.4))
sns.boxplot(data=train[train['vehicle_class'].isin(most_frequent_classes)],
            x='co2_g_km', y='vehicle_class', order=most_frequent_classes,
            color='#D8E0E6', fliersize=2, ax=ax)
ax.set(title='Emissions within frequent classes — training records',
       xlabel='CO₂ (g/km)', ylabel='Vehicle class')
show_figure(fig, '05_training_classes')
Chart generated by the preceding code cell

7. Learn fitting with two small linear models

We first fit engine-only and engine-plus-cylinders models on training records. These are declared learning comparators, not models chosen after inspecting test errors. Both join the validation comparison later.

The fitting and prediction steps have separate cells so their purpose is explicit. No full-data fit is used to report test performance.

Step 37. Choose the engine-only training inputs

X is a table of predictors; y is the target series.

Read / edit Pythoncell 50
engine_X_train = train[['engine_l']]
y_train = train['co2_g_km']

Step 38. Create an engine-only estimator

Creating a model does not fit it yet.

Read / edit Pythoncell 51
engine_model = LinearRegression()

Step 39. Fit the engine-only model

The estimator learns coefficients from training observations only.

Read / edit Pythoncell 52
engine_model.fit(engine_X_train, y_train)
Fitted estimator
LinearRegression()

Step 40. Read the engine-only coefficients

The slope's unit is g/km for one additional litre of engine size.

Read / edit Pythoncell 53
engine_intercept = float(engine_model.intercept_)
engine_slope = float(engine_model.coef_[0])
display(pd.Series({'Intercept (g/km)': engine_intercept,
                   'Slope (g/km per engine litre)': engine_slope}).round(3).to_frame('Estimate'))
Estimate
Intercept (g/km) 134.821
Slope (g/km per engine litre) 36.536
Edit the interpretationcell 54
explain(f"""**Interpretation:** The fitted line is **CO₂ = {engine_intercept:.2f} + {engine_slope:.2f} × engine litres**.
The intercept refers to zero engine size, outside the observed range; it is not a typical vehicle's emissions.""")

Interpretation: The fitted line is CO₂ = 134.82 + 36.54 × engine litres. The intercept refers to zero engine size, outside the observed range; it is not a typical vehicle's emissions.

Step 41. Create one engine-only prediction

Use a DataFrame with the same feature name used during fitting.

Read / edit Pythoncell 55
example_engine = pd.DataFrame({'engine_l': [3.5]})
example_engine_prediction = float(engine_model.predict(example_engine)[0])
print(f'Engine-only estimate for 3.5 L: {example_engine_prediction:.2f} g/km')
Engine-only estimate for 3.5 L: 262.70 g/km

Step 42. Choose the two-predictor training inputs

Two related predictors can describe more of the observed variation.

Read / edit Pythoncell 56
spec_X_train = train[['engine_l', 'cylinders']]

Step 43. Create a two-predictor estimator

This model keeps coefficients in the original units.

Read / edit Pythoncell 57
two_variable_model = LinearRegression()

Step 44. Fit the two-predictor model

Use the same training records as the other models.

Read / edit Pythoncell 58
two_variable_model.fit(spec_X_train, y_train)
Fitted estimator
LinearRegression()

Step 45. Read both coefficients

Each coefficient holds the other predictor fixed in the fitted model.

Read / edit Pythoncell 59
two_variable_intercept = float(two_variable_model.intercept_)
two_variable_slopes = pd.Series(two_variable_model.coef_, index=spec_X_train.columns)
display(two_variable_slopes.round(3).to_frame('Coefficient'))
print(f'Intercept: {two_variable_intercept:.3f} g/km')
Coefficient
engine_l 24.134
cylinders 9.895
Intercept: 118.547 g/km
Edit the interpretationcell 60
explain(f"""**Interpretation:** The fitted equation is **CO₂ = {two_variable_intercept:.2f}
+ {two_variable_slopes['engine_l']:.2f} × engine litres
+ {two_variable_slopes['cylinders']:.2f} × cylinders**.
Because engine size and cylinders are correlated, these are conditional model coefficients,
not independent causal contributions.""")

Interpretation: The fitted equation is **CO₂ = 118.55

  • 24.13 × engine litres
  • 9.90 × cylinders**. Because engine size and cylinders are correlated, these are conditional model coefficients, not independent causal contributions.

Step 46. Predict for a two-predictor example

An estimate from two specifications is not a tax certificate.

Read / edit Pythoncell 61
example_specifications = pd.DataFrame({'engine_l': [3.5], 'cylinders': [6]})
example_spec_prediction = float(two_variable_model.predict(example_specifications)[0])
print(f'Specification estimate, 3.5 L and 6 cylinders: {example_spec_prediction:.2f} g/km')
Specification estimate, 3.5 L and 6 cylinders: 262.39 g/km

8. Compare models fairly

Model Information available Reason for inclusion
Mean baseline None Tests whether learning beats a constant prediction
Engine only Engine size Transparent first model
Engine + cylinders Two numeric specifications Corrected version of the supplied v3 approach
Specifications Ridge Engine, cylinders, fuel code, class Models the specifications-only setting
Fuel only Combined fuel consumption Tests the value of one closely related rating
Fuel-aware Ridge Fuel consumption + specifications Adds fuel and class information
Fuel-interaction Ridge Consumption × fuel-code terms + specifications Allows different consumption slopes by fuel
Fuel-aware random forest Same inputs as fuel-aware Ridge Nonlinear comparator with the same information

All candidate choices below are fixed before final test reporting. Specifications-only and fuel-known models use different information; their difference is not solely an algorithm effect. Scaling is helpful for Ridge's penalty, and all fitted transformations stay inside pipelines.

Step 47. Create the common predictor tables

Make and Model are grouping metadata; they never enter these model features.

Read / edit Pythoncell 62
numeric_features = ['engine_l', 'cylinders', 'combined_l_100km']
categorical_features = ['fuel_type', 'vehicle_class']
features = numeric_features + categorical_features
X_train = train[features].copy()
X_test = test[features].copy()
y_test = test['co2_g_km'].copy()

Step 48. Define numeric preprocessing

Median filling is a reusable safeguard; this verified CSV contains no missing predictors.

Read / edit Pythoncell 63
numeric_preprocessing = Pipeline([
    ('impute', SimpleImputer(strategy='median', keep_empty_features=True)),
    ('scale', StandardScaler()),
])

Step 49. Define categorical preprocessing

One-hot encoding creates indicator columns without imposing an artificial order.

Read / edit Pythoncell 64
categorical_preprocessing = Pipeline([
    ('impute', SimpleImputer(strategy='constant', fill_value='Unknown')),
    ('encode', OneHotEncoder(handle_unknown='ignore', sparse_output=False)),
])

Step 50. Create a numeric-model helper

This helper selects only the declared numeric inputs.

Read / edit Pythoncell 65
def numeric_pipeline(columns, estimator):
    selection = ColumnTransformer([('numeric', 'passthrough', columns)], remainder='drop')
    return Pipeline([('select', selection), ('model', estimator)])

Step 51. Create a mixed-model helper

The helper creates a fresh fitted pipeline for each candidate and validation fold.

Read / edit Pythoncell 66
def mixed_pipeline(numbers, estimator):
    preprocessing = ColumnTransformer([
        ('numeric', clone(numeric_preprocessing), numbers),
        ('categorical', clone(categorical_preprocessing), categorical_features),
    ])
    return Pipeline([('preprocess', preprocessing), ('model', estimator)])

Step 52. Define fuel-consumption interactions

A declared fuel-code indicator multiplies consumption; it uses no target values.

Read / edit Pythoncell 67
def add_fuel_interactions(frame):
    result = frame.copy()
    for fuel in FUEL_CODES:
        result[f'consumption_x_{fuel}'] = result['combined_l_100km'].where(
            result['fuel_type'].eq(fuel), 0.0)
    return result

Step 53. Create the constant baseline

The estimator ignores its input values and predicts the mean training target.

Read / edit Pythoncell 68
models = {}
models['Mean baseline'] = numeric_pipeline(['engine_l'], DummyRegressor(strategy='mean'))

Step 54. Create the engine-only candidate

Use the same pipeline interface so all candidates share the same validation procedure.

Read / edit Pythoncell 69
models['Engine only'] = numeric_pipeline(['engine_l'], LinearRegression())

Step 55. Create the engine-plus-cylinders candidate

This is the training-only version of the supplied notebook model.

Read / edit Pythoncell 70
models['Engine + cylinders'] = numeric_pipeline(['engine_l', 'cylinders'], LinearRegression())

Step 56. Create the specifications Ridge candidate

This candidate does not need a fuel-consumption rating.

Read / edit Pythoncell 71
models['Specifications Ridge'] = mixed_pipeline(['engine_l', 'cylinders'], Ridge(alpha=1.0))

Step 57. Create the fuel-only candidate

This tests whether one reported fuel rating already explains much of the target.

Read / edit Pythoncell 72
models['Fuel only'] = numeric_pipeline(['combined_l_100km'], LinearRegression())

Step 58. Create the fuel-aware Ridge candidate

Ridge uses all declared numeric features and encoded fuel/class categories.

Read / edit Pythoncell 73
models['Fuel-aware Ridge'] = mixed_pipeline(numeric_features, Ridge(alpha=1.0))

Step 59. Create the fuel-interaction candidate

Different fuel-specific consumption terms allow different slopes.

Read / edit Pythoncell 74
interaction_features = ['engine_l', 'cylinders'] + [f'consumption_x_{fuel}' for fuel in FUEL_CODES]
models['Fuel-interaction Ridge'] = Pipeline([
    ('interactions', FunctionTransformer(add_fuel_interactions, validate=False)),
    ('preprocess', mixed_pipeline(interaction_features, Ridge()).named_steps['preprocess']),
    ('model', Ridge(alpha=1.0)),
])

Step 60. Create the random-forest candidate

Settings are fixed demonstration choices, not a claim of optimal tuning.

Read / edit Pythoncell 75
forest = RandomForestRegressor(n_estimators=150, min_samples_leaf=3,
                               random_state=SEED, n_jobs=1)
models['Fuel-aware random forest'] = mixed_pipeline(numeric_features, forest)

Step 61. Create grouped validation folds

Validation groups also stay separate inside the training partition.

Read / edit Pythoncell 76
training_groups = train['group_id'].to_numpy()
group_cv = GroupKFold(n_splits=CV_FOLDS)
cv_splits = list(group_cv.split(X_train, y_train, training_groups))
for fit_rows, validation_rows in cv_splits:
    assert set(training_groups[fit_rows]).isdisjoint(training_groups[validation_rows])

Step 62. Define the scoring metrics

Errors use g/km; R² compares squared error with predicting the validation mean.

Read / edit Pythoncell 77
scoring = {'RMSE': 'neg_root_mean_squared_error',
           'MAE': 'neg_mean_absolute_error', 'R2': 'r2'}

Step 63. Run the validation comparison

Scikit-learn negates error scores because its scoring API treats larger values as better.

Read / edit Pythoncell 78
comparison_rows = []
for name, estimator in models.items():
    scores = cross_validate(estimator, X_train, y_train, cv=cv_splits,
                            scoring=scoring, n_jobs=1, error_score='raise')
    comparison_rows.append({'Model': name,
        'CV_RMSE_g_km': -scores['test_RMSE'].mean(),
        'Fold_SD_g_km': scores['test_RMSE'].std(ddof=1),
        'CV_MAE_g_km': -scores['test_MAE'].mean(),
        'CV_R2': scores['test_R2'].mean()})

Step 64. Display the comparison table

Lower mean validation RMSE is the declared selection criterion.

Read / edit Pythoncell 79
comparison = pd.DataFrame(comparison_rows).set_index('Model').sort_values('CV_RMSE_g_km')
display(comparison.round(3))
CV_RMSE_g_km Fold_SD_g_km CV_MAE_g_km CV_R2
Model
Fuel-interaction Ridge 3.568 1.730 2.276 0.996
Fuel-aware random forest 4.895 2.075 2.567 0.992
Fuel-aware Ridge 5.319 0.614 3.104 0.992
Fuel only 23.668 1.186 14.596 0.833
Specifications Ridge 25.054 1.719 18.692 0.812
Engine + cylinders 30.254 2.570 23.233 0.727
Engine only 30.978 2.668 23.554 0.713
Mean baseline 58.166 3.837 46.254 -0.005

Step 65. Select the fuel-known model

The winner is chosen without using any held-out test score.

Read / edit Pythoncell 80
fuel_known_candidates = ['Fuel only', 'Fuel-aware Ridge', 'Fuel-interaction Ridge',
                         'Fuel-aware random forest', 'Mean baseline']
selected_name = comparison.loc[fuel_known_candidates, 'CV_RMSE_g_km'].idxmin()
print('Selected fuel-known model:', selected_name)
Selected fuel-known model: Fuel-interaction Ridge
Edit the interpretationcell 81
explain(f"""**Interpretation:** **{selected_name}** has the lowest mean grouped-validation RMSE in the
fuel-known setting: **{comparison.loc[selected_name, 'CV_RMSE_g_km']:.2f} g/km**.
The fold standard deviation is **{comparison.loc[selected_name, 'Fold_SD_g_km']:.2f} g/km**;
it describes fold variability and is **not a confidence interval**.""")

Interpretation: Fuel-interaction Ridge has the lowest mean grouped-validation RMSE in the fuel-known setting: 3.57 g/km. The fold standard deviation is 1.73 g/km; it describes fold variability and is not a confidence interval.

Step 66. Select the specifications-only model

Select the strongest specifications-only candidate on the same training folds.

Read / edit Pythoncell 82
specification_candidates = ['Mean baseline', 'Engine only', 'Engine + cylinders', 'Specifications Ridge']
selected_spec_name = comparison.loc[specification_candidates, 'CV_RMSE_g_km'].idxmin()
print('Selected specifications-only model:', selected_spec_name)
Selected specifications-only model: Specifications Ridge

Step 67. Plot validation errors

The plot makes the improvement over simple alternatives easy to compare.

Read / edit Pythoncell 83
fig, ax = plt.subplots(figsize=(9.8, 5.1))
positions = np.arange(len(comparison))
colours = [SCARLET if name == selected_name else NAVY for name in comparison.index]
ax.barh(positions, comparison['CV_RMSE_g_km'], xerr=comparison['Fold_SD_g_km'],
        color=colours, capsize=3)
ax.set_yticks(positions, comparison.index)
ax.invert_yaxis()
ax.set(title='Training-only grouped validation — lower is better',
       xlabel='Mean RMSE (g/km); error bars show fold SD')
show_figure(fig, '06_validation_comparison')
Chart generated by the preceding code cell

9. Measure performance on held-out vehicles

Fit the selected models on all training records, then make the reserved test predictions. We report the selected fuel-known model, the selected specifications-only model, the original two-feature approach and the mean baseline. The comparison is pre-declared; test errors do not choose the winner.

MAE is mean absolute error. RMSE emphasises larger errors. R² is a squared-error comparison with a mean prediction; 0.997 is not “99.7% classification accuracy”. A high score with fuel consumption available mostly reflects closely related physical ratings.

Step 68. Define one metric function

A shared function prevents accidentally scoring different targets or units.

Read / edit Pythoncell 84
def regression_metrics(actual, predicted):
    residual = np.asarray(actual) - np.asarray(predicted)
    return {'MAE_g_km': float(mean_absolute_error(actual, predicted)),
            'RMSE_g_km': float(np.sqrt(mean_squared_error(actual, predicted))),
            'R2': float(r2_score(actual, predicted)) if len(actual) > 1 else np.nan,
            'Bias_g_km': float(residual.mean())}

Step 69. Fit the selected fuel-known model

Only training records enter .fit().

Read / edit Pythoncell 85
selected_model = clone(models[selected_name])
selected_model.fit(X_train, y_train)
Fitted estimator
Pipeline(steps=[('interactions',
                 FunctionTransformer(func=<function add_fuel_interactions at 0x7f499a635ee0>)),
                ('preprocess',
                 ColumnTransformer(transformers=[('numeric',
                                                  Pipeline(steps=[('impute',
                                                                   SimpleImputer(keep_empty_features=True,
                                                                                 strategy='median')),
                                                                  ('scale',
                                                                   StandardScaler())]),
                                                  ['engine_l', 'cylinders',
                                                   'consumption_x_X',
                                                   'consumption_x_Z',
                                                   'consumption_x_D',
                                                   'consumption_x_E',
                                                   'consumption_x_N']),
                                                 ('categorical',
                                                  Pipeline(steps=[('impute',
                                                                   SimpleImputer(fill_value='Unknown',
                                                                                 strategy='constant')),
                                                                  ('encode',
                                                                   OneHotEncoder(handle_unknown='ignore',
                                                                                 sparse_output=False))]),
                                                  ['fuel_type',
                                                   'vehicle_class'])])),
                ('model', Ridge())])

Step 70. Predict held-out ratings

These records have unseen exact Make–Model identities.

Read / edit Pythoncell 86
y_pred = selected_model.predict(X_test)

Step 71. Score the selected fuel-known predictions

Compare predictions with the reserved target in the original g/km unit.

Read / edit Pythoncell 87
selected_metrics = regression_metrics(y_test, y_pred)

Step 72. Fit the specifications-only winner

This model uses no fuel-consumption rating.

Read / edit Pythoncell 88
selected_spec_model = clone(models[selected_spec_name]).fit(X_train, y_train)

Step 73. Predict from specifications only

The fitted pipeline selects its declared inputs from the common test table.

Read / edit Pythoncell 89
spec_predictions = selected_spec_model.predict(X_test)

Step 74. Score the specifications-only predictions

Use the same target and metric function as the fuel-known model.

Read / edit Pythoncell 90
spec_metrics = regression_metrics(y_test, spec_predictions)

Step 75. Fit the corrected two-feature comparator

This reproduces the v3 feature choice with a proper training-only fit.

Read / edit Pythoncell 91
corrected_two_model = clone(models['Engine + cylinders']).fit(X_train, y_train)

Step 76. Predict with the two-feature comparator

This model uses only engine size and cylinders.

Read / edit Pythoncell 92
two_predictions = corrected_two_model.predict(X_test)

Step 77. Score the two-feature comparator

Its held-out error is distinct from the supplied v3 in-sample error.

Read / edit Pythoncell 93
two_metrics = regression_metrics(y_test, two_predictions)

Step 78. Fit the constant baseline

A score is more useful when a reader sees what a trivial prediction would achieve.

Read / edit Pythoncell 94
baseline_model = clone(models['Mean baseline']).fit(X_train, y_train)

Step 79. Predict with the constant baseline

Every test record gets the mean of the training targets.

Read / edit Pythoncell 95
baseline_predictions = baseline_model.predict(X_test)

Step 80. Score the constant baseline

This gives the test-set reference for the more informative models.

Read / edit Pythoncell 96
baseline_metrics = regression_metrics(y_test, baseline_predictions)

Step 81. Compare held-out results

The table uses the same records, target and metric functions.

Read / edit Pythoncell 97
heldout_results = pd.DataFrame({
    f'Fuel known: {selected_name}': selected_metrics,
    f'Specifications: {selected_spec_name}': spec_metrics,
    'Corrected engine + cylinders': two_metrics,
    'Mean baseline': baseline_metrics,
}).T
display(heldout_results.round(3))
MAE_g_km RMSE_g_km R2 Bias_g_km
Fuel known: Fuel-interaction Ridge 2.231 3.328 0.997 -0.040
Specifications: Specifications Ridge 18.147 23.541 0.844 1.323
Corrected engine + cylinders 21.893 28.706 0.768 1.066
Mean baseline 48.036 59.692 -0.001 2.171
Edit the interpretationcell 98
explain(f"""**Interpretation:** With fuel consumption known, the selected model has **MAE
{selected_metrics['MAE_g_km']:.2f} g/km**, **RMSE {selected_metrics['RMSE_g_km']:.2f} g/km** and
**R² {selected_metrics['R2']:.4f}**. Without that rating, the selected specifications-only model
has **RMSE {spec_metrics['RMSE_g_km']:.2f} g/km**. This difference reflects the information available.""")

Interpretation: With fuel consumption known, the selected model has MAE 2.23 g/km, RMSE 3.33 g/km and R² 0.9969. Without that rating, the selected specifications-only model has RMSE 23.54 g/km. This difference reflects the information available.

Step 82. Store residuals with the source identity

A positive residual means the model underpredicts the recorded rating.

Read / edit Pythoncell 99
test_results = test[['source_row', 'group_id', 'make', 'model', 'vehicle_class',
                     'fuel_type', 'combined_l_100km', 'co2_g_km']].copy()
test_results['predicted_g_km'] = y_pred
test_results['residual_g_km'] = test_results['co2_g_km'] - y_pred
test_results['absolute_error_g_km'] = test_results['residual_g_km'].abs()

Step 83. Plot actual versus predicted

The red diagonal is exactly predicted = actual, not a fitted best-fit line.

Read / edit Pythoncell 100
fig, ax = plt.subplots(figsize=(6.6, 6.0))
limits = [min(y_test.min(), y_pred.min()), max(y_test.max(), y_pred.max())]
ax.scatter(y_test, y_pred, color=NAVY, alpha=0.32, s=16, linewidths=0)
ax.plot(limits, limits, color=SCARLET, label='Identity: predicted = actual')
ax.set(xlim=limits, ylim=limits, xlabel='Actual CO₂ (g/km)',
       ylabel='Predicted CO₂ (g/km)', title='Held-out fuel-known predictions')
ax.set_aspect('equal', adjustable='box')
ax.legend()
show_figure(fig, '07_actual_predicted')
Chart generated by the preceding code cell

Step 84. Plot residuals versus predictions

Look for systematic trends, changing spread and exceptional observations.

Read / edit Pythoncell 101
fig, ax = plt.subplots(figsize=(8.3, 4.8))
sns.scatterplot(data=test_results, x='predicted_g_km', y='residual_g_km',
                hue='fuel_type', palette=FUEL_PALETTE, alpha=0.38, s=20, linewidth=0, ax=ax)
ax.axhline(0, color=SCARLET, linestyle='--')
ax.set(title='Held-out residuals by fuel type', xlabel='Predicted CO₂ (g/km)',
       ylabel='Actual − predicted (g/km)')
show_figure(fig, '08_residuals')
Chart generated by the preceding code cell

Step 85. Summarise errors by fuel type

The sample count is part of the interpretation, especially for rare categories.

Read / edit Pythoncell 102
fuel_error_rows = []
for fuel, subset in test_results.groupby('fuel_type'):
    fuel_error_rows.append({'Fuel': fuel, 'Test_records': len(subset),
        **regression_metrics(subset['co2_g_km'], subset['predicted_g_km'])})
fuel_errors = pd.DataFrame(fuel_error_rows).set_index('Fuel')
display(fuel_errors.round(3))
Test_records MAE_g_km RMSE_g_km R2 Bias_g_km
Fuel
D 45 1.095 1.432 0.998 -0.461
E 50 6.203 11.359 0.952 -1.648
X 730 1.974 2.370 0.998 -0.075
Z 641 2.294 2.952 0.997 0.155
Edit the interpretationcell 103
explain(f"""**Interpretation:** E85 has test RMSE **{fuel_errors.loc['E', 'RMSE_g_km']:.2f} g/km** across
**{int(fuel_errors.loc['E', 'Test_records'])} records**, compared with
**{fuel_errors.loc['X', 'RMSE_g_km']:.2f} g/km** for regular gasoline.
Investigate category-specific errors instead of presenting only the overall average.""")

Interpretation: E85 has test RMSE 11.36 g/km across 50 records, compared with 2.37 g/km for regular gasoline. Investigate category-specific errors instead of presenting only the overall average.

Step 86. Summarise errors by vehicle class

A class with very few held-out records provides weak evidence about future performance.

Read / edit Pythoncell 104
class_error_rows = []
for vehicle_class, subset in test_results.groupby('vehicle_class'):
    class_error_rows.append({'Class': vehicle_class, 'Test_records': len(subset),
        **regression_metrics(subset['co2_g_km'], subset['predicted_g_km'])})
class_errors = pd.DataFrame(class_error_rows).set_index('Class')
display(class_errors.sort_values('Test_records', ascending=False).round(3))
Test_records MAE_g_km RMSE_g_km R2 Bias_g_km
Class
MID-SIZE 250 2.063 2.381 0.998 0.111
SUV - SMALL 223 2.137 5.161 0.971 -0.773
COMPACT 195 1.884 2.593 0.995 -0.358
SUBCOMPACT 153 1.896 2.375 0.998 0.546
SUV - STANDARD 133 3.123 3.930 0.993 -0.542
PICKUP TRUCK - STANDARD 128 2.303 3.475 0.988 0.345
FULL-SIZE 106 2.400 2.924 0.997 0.510
TWO-SEATER 103 2.560 3.035 0.998 -0.103
STATION WAGON - SMALL 68 1.751 2.043 0.997 0.467
MINICOMPACT 38 1.543 1.804 0.997 0.380
VAN - PASSENGER 23 3.744 4.084 0.989 0.232
SPECIAL PURPOSE VEHICLE 19 2.320 2.728 0.993 0.127
MINIVAN 15 3.401 3.605 0.868 2.146
STATION WAGON - MID-SIZE 10 3.200 3.450 0.998 -2.897
VAN - CARGO 2 0.687 0.720 0.998 -0.217

Check uncertainty and repeated-row sensitivity

These short extensions strengthen interpretation without hiding the basic workflow. The group bootstrap resamples held-out model groups and does not refit the selected estimator. Its interval is conditional on this fitted model and an exchangeable-group assumption; it does not include model-selection uncertainty or establish national representativeness.

The repeated-row sensitivity comparison uses training-only validation, leaving the held-out test results untouched. Keep the main run and the sensitivity run clearly labelled.

Step 87. Index the held-out groups for resampling

All records from a sampled model group move together.

Read / edit Pythoncell 105
test_group_values = test_results['group_id'].to_numpy()
unique_test_groups = np.unique(test_group_values)
group_positions = [np.flatnonzero(test_group_values == group) for group in unique_test_groups]
squared_errors = test_results['residual_g_km'].to_numpy() ** 2
bootstrap_rng = np.random.default_rng(SEED)

Step 88. Run the group bootstrap

This estimates variability in RMSE across resampled held-out groups.

Read / edit Pythoncell 106
bootstrap_errors = []
for _ in range(N_BOOTSTRAPS):
    sample = bootstrap_rng.integers(0, len(group_positions), size=len(group_positions))
    positions = np.concatenate([group_positions[index] for index in sample])
    bootstrap_errors.append(np.sqrt(squared_errors[positions].mean()))

Step 89. Report the conditional interval

A percentile interval is different from the standard deviation between cross-validation folds.

Read / edit Pythoncell 107
rmse_lower, rmse_upper = np.quantile(bootstrap_errors, [0.025, 0.975])
print(f'Conditional 95% group-bootstrap RMSE interval: {rmse_lower:.2f}–{rmse_upper:.2f} g/km')
Conditional 95% group-bootstrap RMSE interval: 2.64–4.30 g/km

Step 90. Create the deduplicated training view

Exclude tracking and audit columns when identifying repeated source records.

Read / edit Pythoncell 108
source_columns = list(rename_columns.values())
unique_training = train.drop_duplicates(subset=source_columns).copy()
print('Training records before/after repeat removal:', len(train), len(unique_training))
Training records before/after repeat removal: 5919 5035

Step 91. Run validation on the sensitivity view

Use the selected candidate settings, not a new search driven by test performance.

Read / edit Pythoncell 109
dedup_cv = GroupKFold(n_splits=CV_FOLDS)
dedup_scores = cross_validate(models[selected_name], unique_training[features],
    unique_training['co2_g_km'], groups=unique_training['group_id'], cv=dedup_cv,
    scoring=scoring, n_jobs=1, error_score='raise')
dedup_rmse = float(-dedup_scores['test_RMSE'].mean())

Step 92. Compare the repeated-row policies

Removal changes observation weights and fold allocation, so this is sensitivity rather than a paired test.

Read / edit Pythoncell 110
repeat_sensitivity = pd.Series({
    'Retain repeats: grouped CV RMSE': comparison.loc[selected_name, 'CV_RMSE_g_km'],
    'Remove normalised repeats: grouped CV RMSE': dedup_rmse,
}).to_frame('RMSE (g/km)')
display(repeat_sensitivity.round(3))
RMSE (g/km)
Retain repeats: grouped CV RMSE 3.568
Remove normalised repeats: grouped CV RMSE 3.732
Edit the interpretationcell 111
explain(f"""**Interpretation:** Mean grouped-validation RMSE is **{comparison.loc[selected_name, 'CV_RMSE_g_km']:.2f} g/km**
with repeats retained and **{dedup_rmse:.2f} g/km** with repeats removed.
Neither policy establishes the correct source history; restore model years before deciding how to weight records.""")

Interpretation: Mean grouped-validation RMSE is 3.57 g/km with repeats retained and 3.73 g/km with repeats removed. Neither policy establishes the correct source history; restore model years before deciding how to weight records.

Compare with combustion arithmetic

Natural Resources Canada reports approximately 2.3 kg CO₂ per litre of gasoline. That gives a rough gasoline-only rule:

CO_2\ (g/km)\approx 23\times\text{combined fuel consumption (L/100 km)}.

The factor is approximate and not fitted to this CSV. We compare it with the selected estimator on the same X/Z gasoline rows. It does not apply to diesel, E85 or natural gas.

Source: NRCan personal vehicles.

Step 93. Select the comparable gasoline subset

Both methods must be scored on the same observations.

Read / edit Pythoncell 112
gasoline_test = test_results[test_results['fuel_type'].isin(['X', 'Z'])].copy()
gasoline_rule_predictions = 23.0 * gasoline_test['combined_l_100km']

Step 94. Evaluate the gasoline benchmark

A simple scientific rule is a useful alternative to a learned model.

Read / edit Pythoncell 113
physical_results = pd.DataFrame({
    'Approximate 23 × fuel-consumption rule': regression_metrics(
        gasoline_test['co2_g_km'], gasoline_rule_predictions),
    'Selected model on the same gasoline rows': regression_metrics(
        gasoline_test['co2_g_km'], gasoline_test['predicted_g_km']),
}).T
display(physical_results.round(3))
MAE_g_km RMSE_g_km R2 Bias_g_km
Approximate 23 × fuel-consumption rule 3.446 4.285 0.995 3.282
Selected model on the same gasoline rows 2.123 2.658 0.998 0.032
Edit the interpretationcell 114
explain(f"""**Interpretation:** On **{len(gasoline_test):,} gasoline test records**, the rough physical rule has
RMSE **{physical_results.iloc[0]['RMSE_g_km']:.2f} g/km**, versus
**{physical_results.iloc[1]['RMSE_g_km']:.2f} g/km** for the selected estimator.
Much of the predictive strength follows a known fuel/CO₂ relationship. It is not proof of a new mechanism.""")

Interpretation: On 1,371 gasoline test records, the rough physical rule has RMSE 4.29 g/km, versus 2.66 g/km for the selected estimator. Much of the predictive strength follows a known fuel/CO₂ relationship. It is not proof of a new mechanism.

10. Business application: fleet operating scenarios

For a fleet manager, the useful question is often “What changes in annual fuel cost and tailpipe CO₂ if we change the vehicle mix?” The following demonstration uses catalogue class means as proxies, with full-row repeats removed for that summary. Those means are not matched replacement vehicles, current South African ratings, or actual fleet performance.

We compare 100 vehicles at 25,000 km per vehicle per year. The fuel price is an assumed R24/L, not a retrieved current price. All three fleet options have the same vehicle count and distance.

\text{Annual fuel litres}=N\times km\times\frac{L/100\,km}{100}
\text{Annual tailpipe tonnes}=N\times km\times\frac{g/km}{1,000,000}.

Step 95. Create a unique catalogue summary

This view is descriptive and is created after the completed model evaluation.

Read / edit Pythoncell 115
catalogue = data.drop_duplicates(subset=source_columns).copy()
catalogue_classes = catalogue.groupby('vehicle_class').agg(
    entries=('co2_g_km', 'size'), mean_co2_g_km=('co2_g_km', 'mean'),
    mean_fuel_l_100km=('combined_l_100km', 'mean'))
display(catalogue_classes.loc[['COMPACT', 'SUV - SMALL', 'SUV - STANDARD']].round(2))
entries mean_co2_g_km mean_fuel_l_100km
vehicle_class
COMPACT 903 217.64 9.39
SUV - SMALL 1005 236.44 10.25
SUV - STANDARD 613 306.73 13.78

Step 96. Declare the fleet assumptions

Change one assumption at a time to see how the arithmetic responds.

Read / edit Pythoncell 116
FLEET_VEHICLES = 100
FLEET_ANNUAL_KM = 25000
ASSUMED_FUEL_PRICE_ZAR_PER_L = 24.0
fleet_classes = ['COMPACT', 'SUV - SMALL', 'SUV - STANDARD']
fleet_scenario = catalogue_classes.loc[fleet_classes].copy()

Step 97. Calculate annual fuel use

The units are litres; dividing by 100 converts the consumption rating to litres per kilometre.

Read / edit Pythoncell 117
fleet_scenario['annual_fuel_litres'] = (
    FLEET_VEHICLES * FLEET_ANNUAL_KM * fleet_scenario['mean_fuel_l_100km'] / 100)

Step 98. Calculate the fuel budget

This is a scenario budget, not a quoted fleet operating cost.

Read / edit Pythoncell 118
fleet_scenario['annual_fuel_cost_zar'] = (
    fleet_scenario['annual_fuel_litres'] * ASSUMED_FUEL_PRICE_ZAR_PER_L)

Step 99. Calculate annual tailpipe CO₂

One tonne contains one million grams.

Read / edit Pythoncell 119
fleet_scenario['annual_tailpipe_tonnes'] = (
    FLEET_VEHICLES * FLEET_ANNUAL_KM * fleet_scenario['mean_co2_g_km'] / 1_000_000)
display(fleet_scenario.round(2))
entries mean_co2_g_km mean_fuel_l_100km annual_fuel_litres annual_fuel_cost_zar annual_tailpipe_tonnes
vehicle_class
COMPACT 903 217.64 9.39 234833.89 5636013.29 544.10
SUV - SMALL 1005 236.44 10.25 256313.43 6151522.39 591.11
SUV - STANDARD 613 306.73 13.78 344588.09 8270114.19 766.83

Step 100. Compare the two ends of the scenario

Differences are arithmetic consequences of these proxy ratings and assumptions.

Read / edit Pythoncell 120
fleet_cost_difference = float(fleet_scenario.loc['SUV - STANDARD', 'annual_fuel_cost_zar']
                              - fleet_scenario.loc['COMPACT', 'annual_fuel_cost_zar'])
fleet_co2_difference = float(fleet_scenario.loc['SUV - STANDARD', 'annual_tailpipe_tonnes']
                             - fleet_scenario.loc['COMPACT', 'annual_tailpipe_tonnes'])
Edit the interpretationcell 121
explain(f"""**Business interpretation:** Under the declared assumptions, the standard-SUV class proxy uses
**R{fleet_cost_difference:,.0f} more fuel budget per year** and produces
**{fleet_co2_difference:,.1f} more tailpipe tonnes** than the compact class proxy for the 100-vehicle fleet.
This does not establish equivalent payload, safety, suitability, purchase cost, resale value or real-world economy.""")

Business interpretation: Under the declared assumptions, the standard-SUV class proxy uses R2,634,101 more fuel budget per year and produces 222.7 more tailpipe tonnes than the compact class proxy for the 100-vehicle fleet. This does not establish equivalent payload, safety, suitability, purchase cost, resale value or real-world economy.

Step 101. Plot the fleet scenario

Two charts keep currency and tonnes on separate axes.

Read / edit Pythoncell 122
fig, axes = plt.subplots(1, 2, figsize=(10.8, 4.6))
labels = ['Compact', 'Small SUV', 'Standard SUV']
axes[0].bar(labels, fleet_scenario['annual_fuel_cost_zar'] / 1_000_000, color=NAVY)
axes[1].bar(labels, fleet_scenario['annual_tailpipe_tonnes'], color=SCARLET)
axes[0].set(title='Assumed fleet fuel budget', ylabel='R million / year')
axes[1].set(title='Rated tailpipe scenario', ylabel='Tonnes CO₂ / year')
for ax in axes:
    ax.tick_params(axis='x', rotation=15)
show_figure(fig, '09_fleet_scenario')
Chart generated by the preceding code cell

How a business would use this

The inferred business value is a repeatable comparison: reconcile actual vehicles, add distance and fuel prices, then compare options under equivalent operating requirements. Purchasing decisions also require depreciation, maintenance, financing, charging/infrastructure and duty cycle. A vehicle supplier could use validated local ratings and forecast sales to compare product mixes and customer operating scenarios.

Under the GHG Protocol, combustion from owned/controlled vehicles can fall in Scope 1. A manufacturer's sold-product use phase is a different Scope 3 Category 11 boundary, normally assessed over expected lifetime use. The calculations here are tailpipe CO₂ only; they are not a complete CO₂e or lifecycle inventory and do not handle the special accounting of biogenic carbon in E85.

Sources: GHG Protocol Corporate Standard, Scope 3 calculation guidance, Category 11.

11. Business application: tax exposure is jurisdiction-specific

South Africa illustration. The SARS Schedule 1 Part 3D listing was checked on 4 October 2026 (listed update: 1 April 2024). For a qualifying passenger vehicle in this simplified example, use R146 per g/km above 95 g/km. The 2025 SARS policy confirms the certificate-based calculation. Actual liability requires the applicable classification, clearance date, recognised test report and any exclusions. We do not calculate VAT, other duties or non-passenger classifications.

The inputs below are assumed locally valid rated emissions. Historical Canadian catalogue ratings and machine-learning estimates are not substitutes for the required South African report. If the required test evidence is missing, the legal fallback applies; this model is not that fallback.

Sources: SARS current schedules, Part 3D, SARS motor-vehicle emissions policy.

Step 102. Declare the South African illustration parameters

Freeze the checked rate and threshold so the example can be reproduced.

Read / edit Pythoncell 123
SA_PASSENGER_THRESHOLD_G_KM = 95.0
SA_PASSENGER_RATE_ZAR = 146.0
ASSUMED_TAX_UNITS = 1000
sa_tax_examples = pd.DataFrame({'Assumed_certified_g_km': [95, 110, 135, 150, 180]})

Step 103. Calculate the taxable excess

Only the rating above the threshold enters this simplified levy formula.

Read / edit Pythoncell 124
sa_tax_examples['taxable_excess_g_km'] = (
    sa_tax_examples['Assumed_certified_g_km'] - SA_PASSENGER_THRESHOLD_G_KM).clip(lower=0)

Step 104. Calculate levy per unit and scenario total

This is base levy arithmetic, with all eligibility assumptions declared above.

Read / edit Pythoncell 125
sa_tax_examples['levy_per_vehicle_zar'] = sa_tax_examples['taxable_excess_g_km'] * SA_PASSENGER_RATE_ZAR
sa_tax_examples['levy_for_assumed_units_zar'] = sa_tax_examples['levy_per_vehicle_zar'] * ASSUMED_TAX_UNITS
display(sa_tax_examples)
Assumed_certified_g_km taxable_excess_g_km levy_per_vehicle_zar levy_for_assumed_units_zar
0 95 0.0 0.0 0.0
1 110 15.0 2190.0 2190000.0
2 135 40.0 5840.0 5840000.0
3 150 55.0 8030.0 8030000.0
4 180 85.0 12410.0 12410000.0

Step 105. Measure sensitivity to a rating difference

A rating error can matter financially even when a model has a small average error.

Read / edit Pythoncell 126
sa_10g_sensitivity = 10 * SA_PASSENGER_RATE_ZAR * ASSUMED_TAX_UNITS
sa_25g_scenario_difference = 25 * SA_PASSENGER_RATE_ZAR * ASSUMED_TAX_UNITS
Edit the interpretationcell 127
explain(f"""**Business interpretation:** A 10 g/km difference changes this scenario's base levy by
**R{sa_10g_sensitivity:,.0f} across {ASSUMED_TAX_UNITS:,} units**, when both ratings are above the threshold.
Reducing an assumed valid rating from 135 to 110 g/km changes it by
**R{sa_25g_scenario_difference:,.0f}**. These are scenario sensitivities, not tax amounts owed by a named business.""")

Business interpretation: A 10 g/km difference changes this scenario's base levy by R1,460,000 across 1,000 units, when both ratings are above the threshold. Reducing an assumed valid rating from 135 to 110 g/km changes it by R3,650,000. These are scenario sensitivities, not tax amounts owed by a named business.

Canada uses a different tax measure

Canada's Green Levy uses weighted fuel consumption: 55% city plus 45% highway. The official table is CAD 0 below 13 L/100 km, CAD 1,000 for 13–<14, CAD 2,000 for 14–<15, CAD 3,000 for 15–<16, and CAD 4,000 at 16 or more. Eligibility excludes pickups and certain other vehicles. It is not a universal per-gram CO₂ tax.

The illustration below uses assumed eligible passenger vehicles, not every row in this catalogue. Source: CRA current rates of excise taxes, checked 4 October 2026.

Step 106. Create the Canadian tax scenarios

The examples are assumptions selected to demonstrate the slab boundaries.

Read / edit Pythoncell 128
canada_examples = pd.DataFrame({'assumed_weighted_l_100km': [12.9, 13.0, 14.0, 15.0, 16.0]})

Step 107. Apply the Canadian rate bands

Do not round a rating into another band without the applicable measurement rule.

Read / edit Pythoncell 129
ratings = canada_examples['assumed_weighted_l_100km']
canada_examples['illustrative_levy_cad'] = np.select(
    [ratings < 13, ratings < 14, ratings < 15, ratings < 16],
    [0, 1000, 2000, 3000], default=4000)
display(canada_examples)
assumed_weighted_l_100km illustrative_levy_cad
0 12.9 0
1 13.0 1000
2 14.0 2000
3 15.0 3000
4 16.0 4000

Keep the financial and environmental quantities separate

Quantity Unit Interpretation
Vehicle rating g CO₂/km Tailpipe intensity under the rating method
SA passenger illustration ZAR per vehicle A qualifying vehicle levy at a specified date/classification
Canada Green Levy illustration CAD per vehicle An eligible vehicle's fuel-efficiency tax band
Fleet annual scenario tonnes CO₂/year Rated intensity multiplied by assumed fleet kilometres
Sold-product use phase typically lifetime CO₂e A separate corporate Scope 3 reporting boundary
National road inventory tonnes CO₂/year A territorial inventory with fleet/activity/fuel evidence

None of these amounts is atmospheric concentration in ppm. Do not add ZAR and CAD amounts, or confuse a one-vehicle fiscal charge with recurring tonnes of emissions.

12. Audit application: screen, trace, corroborate

An audit perspective asks whether records are complete, correctly measured, properly classified and supported by evidence. Model residuals can prioritise review, but cannot establish fraud, certify emissions, or independently verify the same physical rating system they were trained on.

We use training-only out-of-fold predictions to set a 99th-percentile screening threshold. The threshold is a declared analytics choice, not statutory materiality. It also reflects model selection on the training data and is not an independent confidence statement.

Step 108. Generate out-of-fold training predictions

Each training row is predicted by an estimator fitted without its model group.

Read / edit Pythoncell 130
oof_predictions = cross_val_predict(models[selected_name], X_train, y_train,
                                    cv=cv_splits, n_jobs=1)

Step 109. Set a training-derived review threshold

Do not choose a cutoff by inspecting the largest test errors.

Read / edit Pythoncell 131
oof_absolute_errors = np.abs(y_train.to_numpy() - oof_predictions)
AUDIT_REVIEW_QUANTILE = 0.99
audit_threshold = float(np.quantile(oof_absolute_errors, AUDIT_REVIEW_QUANTILE))
print(f'Training-derived review threshold: {audit_threshold:.2f} g/km')
Training-derived review threshold: 7.16 g/km

Step 110. Create a held-out review queue

Keep source rows and vehicle identities so a reviewer can trace the input.

Read / edit Pythoncell 132
audit_review = test_results[test_results['absolute_error_g_km'] > audit_threshold].copy()
audit_review = audit_review.sort_values('absolute_error_g_km', ascending=False)
display(audit_review.head(10).round(2))
source_row group_id make model vehicle_class fuel_type combined_l_100km co2_g_km predicted_g_km residual_g_km absolute_error_g_km
4083 4083 1490 MERCEDES-BENZ GLA 250 4MATIC SUV - SMALL E 12.0 128 197.54 -69.54 69.54
3545 3545 514 CHEVROLET SILVERADO 4WD FFV PICKUP TRUCK - STANDARD E 17.9 315 293.24 21.76 21.76
2971 2971 1378 MERCEDES-BENZ AMG G 65 SUV - STANDARD Z 21.4 476 497.10 -21.10 21.10
7169 7169 1366 MERCEDES-BENZ AMG CLS 53 4MATIC+ COMPACT Z 10.7 235 249.01 -14.01 14.01
6160 6160 1366 MERCEDES-BENZ AMG CLS 53 4MATIC+ COMPACT Z 10.7 235 249.01 -14.01 14.01
2987 2987 1444 MERCEDES-BENZ C 450 AMG SPORT 4MATIC COMPACT Z 10.4 231 242.17 -11.17 11.17
2972 2972 1379 MERCEDES-BENZ AMG GL 63 S SUV - STANDARD Z 16.3 369 378.81 -9.81 9.81
4601 4601 514 CHEVROLET SILVERADO 4WD FFV PICKUP TRUCK - STANDARD E 18.3 309 299.53 9.47 9.47
2975 2975 1393 MERCEDES-BENZ AMG GLE 63 S COUPE 4MATIC SUV - STANDARD Z 15.2 363 353.75 9.25 9.25
2981 2981 1423 MERCEDES-BENZ AMG SL 63 TWO-SEATER Z 12.4 298 288.98 9.02 9.02
Edit the interpretationcell 133
explain(f"""**Audit interpretation:** **{len(audit_review):,} held-out records** exceed the training-derived
screening cutoff of **{audit_threshold:.2f} g/km**. These are records to corroborate, not confirmed misstatements.""")

Audit interpretation: 15 held-out records exceed the training-derived screening cutoff of 7.16 g/km. These are records to corroborate, not confirmed misstatements.

Step 111. Examine the largest exception

A numerical example shows why the review needs original supporting evidence.

Read / edit Pythoncell 134
largest_exception = audit_review.iloc[0]
display(largest_exception.to_frame('Largest held-out exception'))
Largest held-out exception
source_row 4083
group_id 1490
make MERCEDES-BENZ
model GLA 250 4MATIC
vehicle_class SUV - SMALL
fuel_type E
combined_l_100km 12.0
co2_g_km 128
predicted_g_km 197.544233
residual_g_km -69.544233
absolute_error_g_km 69.544233
Edit the interpretationcell 135
explain(f"""**Observed exception:** Source row **{int(largest_exception['source_row'])}** lists
**{largest_exception['make']} {largest_exception['model']}**, fuel code **{largest_exception['fuel_type']}**,
consumption **{largest_exception['combined_l_100km']:.1f} L/100 km**, and **{largest_exception['co2_g_km']:.0f} g/km**.
The model estimates **{largest_exception['predicted_g_km']:.1f} g/km**, a gap of
**{largest_exception['absolute_error_g_km']:.1f} g/km**. Check the source row, variant, fuel code,
rating method and original report before deciding whether the record is wrong.""")

Observed exception: Source row 4083 lists MERCEDES-BENZ GLA 250 4MATIC, fuel code E, consumption 12.0 L/100 km, and 128 g/km. The model estimates 197.5 g/km, a gap of 69.5 g/km. Check the source row, variant, fuel code, rating method and original report before deciding whether the record is wrong.

Step 112. Review the arithmetic flags separately

A rating-formula discrepancy is a different control from a model residual.

Read / edit Pythoncell 136
rating_review = data.loc[data['combined_rating_review'],
    ['source_row', 'make', 'model', 'fuel_type', 'city_l_100km', 'hwy_l_100km',
     'combined_l_100km', 'combined_gap_l_100km']].copy()
display(rating_review.reindex(rating_review['combined_gap_l_100km'].abs().sort_values(ascending=False).index).head(10))
source_row make model fuel_type city_l_100km hwy_l_100km combined_l_100km combined_gap_l_100km
2641 2641 FORD TAURUS FFV AWD X 13.6 9.8 12.4 0.510
3725 3725 FORD TAURUS FFV AWD X 13.6 9.9 12.4 0.465
4796 4796 FORD TAURUS FFV AWD X 13.6 9.9 12.4 0.465
5057 5057 LEXUS RC 300 AWD Z 13.1 9.8 11.2 -0.415
2584 2584 FORD F-150 (Payload Pkg) X 13.1 9.8 11.2 -0.415
6087 6087 LEXUS RC 350 AWD Z 13.1 9.8 11.2 -0.415
6086 6086 LEXUS RC 300 AWD Z 13.1 9.8 11.2 -0.415
7103 7103 LEXUS RC 300 AWD Z 13.1 9.8 11.2 -0.415
7104 7104 LEXUS RC 350 AWD Z 13.1 9.8 11.2 -0.415
5058 5058 LEXUS RC 350 AWD Z 13.1 9.8 11.2 -0.415

What an auditor would request next

Assertion / control Evidence or procedure
Completeness Reconcile vehicle quantities to production/import records, stock movements, invoices and levy returns
Accuracy Match exact local variant and test method to a recognised laboratory report; trace spreadsheet formulas
Classification Confirm legal vehicle category and exemptions instead of mapping a catalogue class automatically
Cutoff and rate Match the clearance/reporting date to the effective regulation and rate
Levy calculation Reperform taxable excess × rate × eligible quantity; reconcile to the return and ledger
Emissions boundary Distinguish owned-fleet activity, lifetime sold-product use and territorial national emissions
Data/model governance Retain raw input hash, changes, group splits, model settings, assumptions and reviewer sign-off

For the SA manufacturer example, SARS requires a report from an NRCS-recognised testing laboratory and retained evidence. A model estimate can support planning and exception screening; it cannot replace that evidence. Sources: SARS motor-vehicle policy, GHG Protocol Corporate Standard.

13. Country perspective: use sales weights and activity

To explore what vehicle types being sold could imply, begin with a sales/registration-weighted rating, not the mean of catalogue entries:

\bar E_{sales}=\frac{\sum_i N_i E_i}{\sum_i N_i}.

For a new-sales cohort driven a full assumed year:

\text{Cohort tonnes CO₂/year}=\frac{\sum_i N_i K_i E_i}{1,000,000}.

N is vehicles, K is kilometres per vehicle and E is g/km. The following mixes are hypothetical. Class-average intensities come from the unique Canadian catalogue view; volumes and mileage do not. No actual national sales, emissions, or market response is estimated here.

Step 113. Declare two hypothetical sales mixes

Both mixes have 100,000 vehicles so a volume difference does not explain their comparison.

Read / edit Pythoncell 137
sales_mix = pd.DataFrame({
    'Baseline mix': [25000, 45000, 30000],
    'More compact mix': [50000, 35000, 15000],
}, index=['COMPACT', 'SUV - SMALL', 'SUV - STANDARD'])
COHORT_ANNUAL_KM = 20000
display(sales_mix)
Baseline mix More compact mix
COMPACT 25000 50000
SUV - SMALL 45000 35000
SUV - STANDARD 30000 15000

Step 114. Attach the proxy rating for each class

In a real analysis, join registrations to matching local year/variant ratings.

Read / edit Pythoncell 138
sales_proxy_intensity = catalogue_classes.loc[sales_mix.index, 'mean_co2_g_km']
display(sales_proxy_intensity.round(2).to_frame('Catalogue proxy g/km'))
Catalogue proxy g/km
COMPACT 217.64
SUV - SMALL 236.44
SUV - STANDARD 306.73

Step 115. Compute a sales-weighted rating

The weights are assumed vehicle counts, not catalogue record frequencies.

Read / edit Pythoncell 139
cohort_counts = sales_mix.sum(axis=0)
sales_weighted_g_km = sales_mix.mul(sales_proxy_intensity, axis=0).sum(axis=0) / cohort_counts
display(sales_weighted_g_km.round(2).to_frame('Assumed sales-weighted g/km'))
Assumed sales-weighted g/km
Baseline mix 252.83
More compact mix 237.59

Step 116. Convert to full-year cohort tonnes

This is a full-year equivalent for the new cohort, not the observed year-of-sale total.

Read / edit Pythoncell 140
cohort_annual_tonnes = sales_weighted_g_km * cohort_counts * COHORT_ANNUAL_KM / 1_000_000
display(cohort_annual_tonnes.round(1).to_frame('Full-year cohort tailpipe tonnes'))
Full-year cohort tailpipe tonnes
Baseline mix 505658.5
More compact mix 475171.1

Step 117. Compare the hypothetical mixes

Hold distance and cohort size fixed while changing the assumed mix.

Read / edit Pythoncell 141
cohort_difference_tonnes = float(cohort_annual_tonnes['Baseline mix'] - cohort_annual_tonnes['More compact mix'])
cohort_difference_pct = cohort_difference_tonnes / cohort_annual_tonnes['Baseline mix'] * 100
Edit the interpretationcell 142
explain(f"""**Policy interpretation:** The more-compact mix is **{cohort_difference_tonnes:,.0f} tonnes/year lower**
for this assumed new cohort (**{cohort_difference_pct:.1f}%**). This is a scenario consequence of
the selected ratings, 100,000 vehicles and {COHORT_ANNUAL_KM:,} km each. It is not Canada's or
South Africa's actual national reduction, and it does not estimate atmospheric ppm.""")

Policy interpretation: The more-compact mix is 30,487 tonnes/year lower for this assumed new cohort (6.0%). This is a scenario consequence of the selected ratings, 100,000 vehicles and 20,000 km each. It is not Canada's or South Africa's actual national reduction, and it does not estimate atmospheric ppm.

Step 118. Show the distance sensitivity

A lower rating does not guarantee a lower total if the fleet drives more kilometres.

Read / edit Pythoncell 143
mileage_values = pd.Series([10000, 20000, 30000], name='Assumed km per vehicle')
distance_sensitivity = pd.DataFrame({
    mix: sales_weighted_g_km[mix] * cohort_counts[mix] * mileage_values / 1_000_000
    for mix in sales_mix.columns})
distance_sensitivity.index = mileage_values
display(distance_sensitivity.round(1))
Baseline mix More compact mix
Assumed km per vehicle
10000 252829.3 237585.5
20000 505658.5 475171.1
30000 758487.8 712756.6

Step 119. Plot the sales-cohort scenario

The title and axes explicitly identify the hypothetical nature and units.

Read / edit Pythoncell 144
fig, ax = plt.subplots(figsize=(8.3, 4.8))
ax.bar(['Baseline mix', 'More compact mix'], cohort_annual_tonnes.values / 1000,
       color=[NAVY, SCARLET])
ax.set(title='Hypothetical new-sales cohort — full-year tailpipe scenario',
       ylabel='Thousand tonnes CO₂ / year')
show_figure(fig, '10_sales_cohort_scenario')
Chart generated by the preceding code cell

What is required for a national estimate?

Missing evidence Why it changes the answer
Actual sales/registrations by year, class and fuel Establishes market weights and change over time
Exact year and variant ratings Prevents joining historical or mismatched model entries
In-use vehicle stock, survival and scrappage New sales are only part of the fleet operating this year
Distance and real-world consumption by type Converts intensity to activity; accounts for operating conditions
Road-transport fuel sales and carbon factors Reconciles totals with the national fuel-based inventory
EV/PHEV, heavy vehicles and other missing types Completes fleet coverage and distinguishes electricity from tailpipe activity
Imports/exports, territory and inventory boundary Aligns activity with the country and period being counted

Statistics Canada publishes registration data by fuel/type/geography; matching classifications and years would be a next step, not an automatic join to this file. IPCC guidance estimates road CO₂ primarily from fuel consumption and carbon content and recommends reconciling activity information. Biogenic carbon needs separate treatment. A sales-cohort scenario supplements that work; it is not a replacement national inventory or a ppm model.

Sources: Statistics Canada registration table 20-10-0025-01, IPCC 2006 Guidelines, Volume 2 Chapter 3.

14. Summarise the value without overstating it

The strongest portfolio evidence is the combination of reproducible code and clear judgement: recognise the data's limits, choose validation that reduces repeated-model overlap, compare against simple alternatives, and connect metrics to a specific decision.

This notebook estimates recorded vehicle ratings. It does not certify new designs, prove fraud, calculate a named company's tax bill, establish premium-fuel causality, or measure a country's atmospheric CO₂ concentration. Current local certificates, activity and market data would be needed to use the workflow operationally.

Edit the interpretationcell 145
explain(f"""**Verified model summary:** The selected fuel-known estimator is **{selected_name}**, with
held-out **MAE {selected_metrics['MAE_g_km']:.2f} g/km**, **RMSE {selected_metrics['RMSE_g_km']:.2f} g/km**
and **R² {selected_metrics['R2']:.4f}** across **{len(test):,} records** in **{test['group_id'].nunique():,} groups**.
The corrected engine-and-cylinders comparator has **RMSE {two_metrics['RMSE_g_km']:.2f} g/km**.

**Business value:** compare rated operating scenarios and jurisdiction-specific fiscal sensitivity.
**Audit value:** prioritise traceable exceptions and require evidence before making conclusions.
**Policy value:** demonstrate how sales weights and vehicle activity affect a new-cohort emissions scenario.""")

Verified model summary: The selected fuel-known estimator is Fuel-interaction Ridge, with held-out MAE 2.23 g/km, RMSE 3.33 g/km and R² 0.9969 across 1,466 records in 410 groups. The corrected engine-and-cylinders comparator has RMSE 28.71 g/km.

Business value: compare rated operating scenarios and jurisdiction-specific fiscal sensitivity. Audit value: prioritise traceable exceptions and require evidence before making conclusions. Policy value: demonstrate how sales weights and vehicle activity affect a new-cohort emissions scenario.

Learning exercises

  1. Explain why a model can have high R² but still be unsuitable for certifying a tax rating.
  2. Change the fleet mileage assumption. Which outputs double if mileage doubles?
  3. Change only the fuel-price assumption. Why do estimated tonnes stay the same?
  4. Compare retained and removed repeated rows. What source information would settle the policy?
  5. Identify why a low-emission catalogue mean is different from a sales-weighted mean.
  6. Find the largest audit exception and list the original documents needed to resolve it.
  7. Explain why Scope 1, lifetime sold-product use and a national territorial total cannot be added blindly.

For your website, lead with the question and verified results. Show the fuel relationship, validation comparison, residual plot and one labelled business scenario. Make full code available through this notebook or GitHub, and keep assumptions next to their scenario results.

15. Optional exports and run record

SAVE_OUTPUTS=False prevents the notebook from writing charts and tables. Set it to True before Run All to save the figures and the following traceable result files locally. Publishing the notebook does not automatically update a hosted website or tax-rate snapshot.

Step 120. Record the runtime versions

Versions, input hash and settings make a result easier to reproduce.

Read / edit Pythoncell 146
versions = {'python': platform.python_version(), 'numpy': np.__version__,
            'pandas': pd.__version__, 'matplotlib': matplotlib.__version__,
            'seaborn': sns.__version__, 'scikit-learn': sklearn.__version__}
display(pd.Series(versions).to_frame('Version'))
Version
python 3.12.14
numpy 2.3.5
pandas 2.2.3
matplotlib 3.10.8
seaborn 0.13.2
scikit-learn 1.8.0

Step 121. Prepare a model-run record

Keep input provenance and selection information with the measured results.

Read / edit Pythoncell 147
run_record = {
    'source_url': DATASET_URL, 'source_access_date': SOURCE_ACCESS_DATE,
    'source_licence': SOURCE_LICENCE, 'source_sha256': source_sha256,
    'versions': versions, 'seed': SEED, 'test_group_share': TEST_GROUP_SHARE,
    'cv_folds': CV_FOLDS, 'selected_model': selected_name,
    'specifications_model': selected_spec_name, 'test_records': len(test),
    'test_groups': int(test['group_id'].nunique()), 'heldout_metrics': selected_metrics,
    'tax_sources_checked': SOURCE_CHECKED_DATE,
}

Step 122. Export the principal result tables if enabled

These exports have clear names and do not alter the original CSV.

Read / edit Pythoncell 148
if SAVE_OUTPUTS:
    OUTPUT_DIR.mkdir(exist_ok=True)
    comparison.to_csv(OUTPUT_DIR / 'grouped_validation.csv')
    heldout_results.to_csv(OUTPUT_DIR / 'heldout_metrics.csv')
    test_results.to_csv(OUTPUT_DIR / 'heldout_predictions.csv', index=False)
    fuel_errors.to_csv(OUTPUT_DIR / 'heldout_fuel_errors.csv')
    class_errors.to_csv(OUTPUT_DIR / 'heldout_class_errors.csv')
    audit_review.to_csv(OUTPUT_DIR / 'audit_review_queue.csv', index=False)

Step 123. Export the scenario tables if enabled

Each scenario retains its own units and assumptions.

Read / edit Pythoncell 149
if SAVE_OUTPUTS:
    fleet_scenario.to_csv(OUTPUT_DIR / 'illustrative_fleet_scenario.csv')
    sa_tax_examples.to_csv(OUTPUT_DIR / 'illustrative_sa_levy.csv', index=False)
    canada_examples.to_csv(OUTPUT_DIR / 'illustrative_canadian_levy.csv', index=False)
    sales_mix.to_csv(OUTPUT_DIR / 'hypothetical_sales_mix.csv')
    cohort_annual_tonnes.to_csv(OUTPUT_DIR / 'hypothetical_cohort_tonnes.csv')
    (OUTPUT_DIR / 'run_record.json').write_text(json.dumps(run_record, indent=2), encoding='utf-8')

References and provenance

Dataset supplied by the user: CO2 Emissions_Canada.csv. The v3 notebook cites Dovudkhon's Kaggle dataset, accessed 1 April 2024. The precise year range and licence are not independently verified here; model year is absent from the supplied CSV. Official NRCan data is background context, not a claim that this local file was downloaded directly from the government portal.

Technical sources

Business, tax, audit and national context — checked 4 October 2026

The notebook proposes potential applications from those sources and the measured model results. It does not claim that the proposed workflow has been validated for statutory tax, external assurance or national emissions accounting.

Executed on the supplied CSV · 4 October 2026 · 149 small code cells · Tax sources and scenario assumptions are documented above. Get the notebook
Code copied to clipboard