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:
With two predictors:
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')
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')
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')
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')
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')
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')
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')
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')
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:
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.
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')
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:
For a new-sales cohort driven a full assumed year:
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')
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
- Explain why a model can have high R² but still be unsuitable for certifying a tax rating.
- Change the fleet mileage assumption. Which outputs double if mileage doubles?
- Change only the fuel-price assumption. Why do estimated tonnes stay the same?
- Compare retained and removed repeated rows. What source information would settle the policy?
- Identify why a low-emission catalogue mean is different from a sales-weighted mean.
- Find the largest audit exception and list the original documents needed to resolve it.
- 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
- scikit-learn: linear regression.
- scikit-learn: grouped cross-validation.
- scikit-learn: preprocessing and leakage.
- scikit-learn: regression metrics and baselines.
- NRCan: personal vehicles and gasoline CO₂.
Business, tax, audit and national context — checked 4 October 2026
- SARS: current Customs and Excise schedules, Schedule 1 Part 3D listing.
- SARS: Schedule 1 Part 3D.
- SARS: motor-vehicle emissions external policy, effective 30 June 2025.
- CRA: current rates of excise taxes, Green Levy bands and exclusions.
- GHG Protocol: Corporate Standard, reporting boundaries and verification context.
- GHG Protocol: Scope 3 calculation guidance, Category 11, expected use phase of sold products.
- IPCC: 2006 Guidelines, Volume 2 Chapter 3, road-transport methods and reconciliation.
- Statistics Canada: table 20-10-0025-01, new motor vehicle registrations.
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.