Daniel Huencho | AI Engineer
  • Home
  • About
  • Projects

On this page

  • Introduction
  • Data Extraction
  • Schema Discovery
    • Metadata Files
    • Building Categories
    • Consumption Data Schemas
  • Dataset Overview
    • Metering Hierarchy
    • Temporal Coverage
    • Descriptive Statistics
    • Data Quality Assessment
  • Key Visualisations
    • Daily Electricity Consumption
    • Consumption Distributions by Utility
    • Weather vs Electricity Correlation
    • Temperature vs Consumption Scatter
  • Summary
    • Next Steps: Probabilistic Modelling
  • Probabilistic Modelling Framework
    • Compositional Kernel Design
    • Predictive Distribution and Training
    • Kernel Decomposition for Interpretability
    • Anomaly Detection
  • Data Preparation for GP Modelling
  • Single-Building GP Model
  • Kernel Decomposition Analysis
  • Weather Response Analysis
  • Intervention Impact Analysis (ECM + COVID-19)
  • Anomaly Detection
  • Cross-Building Category Comparison
  • Model Evaluation and Discussion
    • Discussion: Hypothesis Testing Results
    • Limitations and Future Work
    • References

UNICON Dataset: Exploratory Data Analysis

Python
EDA
Energy
Time Series
Gaussian Processes
Probabilistic ML
PyTorch
Exploring the UNICON open dataset — electricity, gas, and water consumption across La Trobe University’s five campuses (2018–2021). Schema discovery, temporal analysis, and weather correlations.
Author

Daniel Huencho

Published

January 26, 2026

Introduction

UNICON (UNIversity CONsumption) is a large-scale open dataset of electricity, gas, and water consumption from La Trobe University, Victoria, Australia. It covers 5 campuses, 71 buildings, and spans 2018–2021 — capturing the full COVID-19 pandemic period.

This EDA explores the dataset structure, data quality, temporal patterns, and weather correlations — establishing a foundation for probabilistic energy consumption modelling.

Key features:

  • Three utility types: electricity (15-min), gas (hourly), water (daily)
  • Hierarchical metering: campus NMI → building → submeter
  • Weather data at 1-minute granularity from 5 stations
  • Academic calendar and energy conservation event annotations

Data Extraction

The dataset is distributed as a single zip archive (~142 MB compressed, ~950 MB uncompressed) containing 11 CSV files. We extract idempotently — skipping extraction if the files already exist.

Code
if not EXTRACT_DIR.exists():
    print(f"Extracting {ZIP_PATH} to {EXTRACT_DIR}...")
    with zipfile.ZipFile(ZIP_PATH, 'r') as zf:
        zf.extractall(EXTRACT_DIR)
    print("Done.")
else:
    print(f"Data already extracted at {EXTRACT_DIR}")

extracted_files = sorted(EXTRACT_DIR.glob("*.csv"))
print(f"\nFound {len(extracted_files)} CSV files:")
for f in extracted_files:
    size_mb = f.stat().st_size / (1024 * 1024)
    print(f"  {f.name:45s} {size_mb:>8.1f} MB")
Data already extracted at ../../data/raw/unicon

Found 11 CSV files:
  building_consumption.csv                         251.2 MB
  building_meta.csv                                  0.0 MB
  building_submeter_consumption.csv                113.3 MB
  calender.csv                                       0.0 MB
  campus_meta.csv                                    0.0 MB
  events.csv                                         0.0 MB
  gas_consumption.csv                                0.9 MB
  nmi_consumption.csv                              167.4 MB
  nmi_meta.csv                                       0.0 MB
  water_consumption.csv                              7.7 MB
  weather_data.csv                                 366.3 MB

Schema Discovery

We load each file and inspect its structure — columns, data types, row counts, null values, and date ranges where applicable.

Code
# Load small metadata files fully
campus_meta = pd.read_csv(EXTRACT_DIR / "campus_meta.csv")
building_meta = pd.read_csv(EXTRACT_DIR / "building_meta.csv")
nmi_meta = pd.read_csv(EXTRACT_DIR / "nmi_meta.csv")
calendar = pd.read_csv(EXTRACT_DIR / "calender.csv", parse_dates=["date"])
events = pd.read_csv(EXTRACT_DIR / "events.csv", parse_dates=["date"])

# Load consumption files with optimized dtypes
gas = pd.read_csv(
    EXTRACT_DIR / "gas_consumption.csv",
    parse_dates=["timestamp"],
    dtype={"campus_id": "float32", "consumption": "float32"},
)
water = pd.read_csv(
    EXTRACT_DIR / "water_consumption.csv",
    parse_dates=["timestamp"],
    dtype={"campus_id": "float32", "meter_id": "float32", "consumption": "float32"},
)

# For large files, load with optimized dtypes
# Note: some columns have NAs so we use float types to handle nullable values
building_consumption = pd.read_csv(
    EXTRACT_DIR / "building_consumption.csv",
    parse_dates=["timestamp"],
    dtype={"campus_id": "float32", "meter_id": "float32", "consumption": "float32"},
)
nmi_consumption = pd.read_csv(
    EXTRACT_DIR / "nmi_consumption.csv",
    parse_dates=["timestamp"],
    dtype={"campus_id": "float32", "meter_id": "float32", "consumption": "float32", "demand_kW": "float32", "demand_kVA": "float32"},
)
submeter = pd.read_csv(
    EXTRACT_DIR / "building_submeter_consumption.csv",
    parse_dates=["timestamp"],
    dtype={
        "id": "float32", "building_id": "float32", "campus_id": "float32",
        "consumption": "float32", "current": "float32",
        "voltage": "float32", "power": "float32", "power_factor": "float32",
    },
)
weather = pd.read_csv(
    EXTRACT_DIR / "weather_data.csv",
    parse_dates=["timestamp"],
    dtype={
        "campus_id": "float32",
        "apparent_temperature": "float32", "air_temperature": "float32",
        "dew_point_temperature": "float32", "relative_humidity": "float32",
        "wind_speed": "float32", "wind_direction": "float32",
    },
)

Metadata Files

Code
campus_meta
Table 1: Campus metadata — La Trobe University’s 5 campuses
id name capacity
0 1 Bundoora 26000
1 2 Albury-Wodonga 800
2 3 Bendigo 5000
3 4 Mildura 500
4 5 Shepparton 700
Code
building_meta.head(15)
Table 2: Building metadata — 71 buildings across all campuses
campus_id id built_year category gross_floor_area room_area capacity
0 1 1 NaN other NaN NaN NaN
1 1 2 NaN other NaN NaN NaN
2 1 3 NaN other NaN NaN NaN
3 1 4 1967.0 mixed use 145558.14 1790.17 79.0
4 1 5 1899.0 other 0.00 NaN NaN
5 1 6 1998.0 other 6210.75 473.03 0.0
6 1 7 1980.0 teaching 176996.61 2377.77 799.0
7 2 8 1998.0 library 2395.00 NaN NaN
8 2 9 1990.0 teaching 952.00 NaN NaN
9 2 10 2007.0 teaching 309.00 NaN NaN
10 2 11 1994.0 teaching 2682.00 NaN NaN
11 2 12 2007.0 other 154.00 NaN NaN
12 2 13 1999.0 teaching 1404.00 NaN NaN
13 2 14 2004.0 mixed use 1245.00 NaN NaN
14 1 15 1967.0 other 15728.40 1709.43 0.0
Code
nmi_meta
Table 3: NMI (National Metering Identifier) metadata — primary campus electricity meters
id campus_id peak_demand
0 1 1 1225.890
1 2 4 121.000
2 3 5 145.000
3 4 3 143.000
4 5 1 260.000
5 6 2 100.000
6 7 1 90.000
7 8 2 930.000
8 9 1 22.680
9 10 3 626.000
10 11 3 NaN
11 12 1 7659.000
12 13 1 120.000
13 14 1 192.751
Code
pd.concat([calendar.head(5), calendar.tail(5)])
Table 4: Academic calendar — first and last 5 entries
date is_holiday is_semester is_exam
0 2016-01-01 1.0 0 0
1 2016-01-02 0.0 0 0
2 2016-01-03 0.0 0 0
3 2016-01-04 0.0 0 0
4 2016-01-05 0.0 0 0
2307 2022-04-26 0.0 0 0
2308 2022-04-27 0.0 0 0
2309 2022-04-28 0.0 0 0
2310 2022-04-29 0.0 0 0
2311 2022-04-30 0.0 0 0
Code
events.head(10)
Table 5: Energy conservation events — sample entries
meter_id event_type date event_description
0 4 Misc 2021-07-01 Decommissioned the L1 AHU
1 5 HVAC_Tuning 2019-05-10 HVAC System Tuning
2 5 HVAC_Tuning 2020-04-21 HVAC System Tuning
3 6 COVID_19_Building_Shutdown 2020-04-02 Building Shutdown due to the COVID-19 Lockdown
4 7 HVAC_Tuning 2019-06-07 HVAC System Tuning
5 7 HVAC_Tuning 2020-03-07 HVAC System Tuning
6 8 COVID_19_Building_Shutdown 2020-04-02 Building Shutdown due to the COVID-19 Lockdown
7 9 COVID_19_Building_Shutdown 2020-03-07 Building Shutdown due to the COVID-19 Lockdown
8 10 COVID_19_Building_Shutdown 2020-03-17 Building Shutdown due to the COVID-19 Lockdown
9 11 COVID_19_Building_Shutdown 2020-03-29 Building Shutdown due to the COVID-19 Lockdown

Building Categories

Code
building_cats = building_meta.groupby(["campus_id", "category"]).size().unstack(fill_value=0)
building_cats.loc["Total"] = building_cats.sum()
building_cats
Table 6: Buildings by category and campus
category leased library mixed use office other residence sport teaching
campus_id
1 1 1 24 3 12 2 2 7
2 0 1 1 0 1 2 0 4
3 0 0 1 0 1 1 0 0
Total 1 2 26 3 14 5 2 11

Consumption Data Schemas

Code
def schema_report(df, name):
    """Generate a concise schema report for a dataframe."""
    info = pd.DataFrame({
        "dtype": df.dtypes.astype(str),
        "non_null": df.count(),
        "null_count": df.isnull().sum(),
        "null_pct": (df.isnull().sum() / len(df) * 100).round(2),
    })

    ts_col = "timestamp" if "timestamp" in df.columns else ("date" if "date" in df.columns else None)
    date_range = ""
    if ts_col and pd.api.types.is_datetime64_any_dtype(df[ts_col]):
        date_range = f"{df[ts_col].min()} → {df[ts_col].max()}"

    print(f"{'='*60}")
    print(f"  {name}")
    print(f"  Rows: {len(df):,}  |  Columns: {len(df.columns)}  |  Memory: {df.memory_usage(deep=True).sum() / 1e6:.1f} MB")
    if date_range:
        print(f"  Date range: {date_range}")
    print(f"{'='*60}")
    return info

datasets = {
    "NMI Consumption (campus electricity)": nmi_consumption,
    "Building Consumption": building_consumption,
    "Submeter Consumption": submeter,
    "Gas Consumption": gas,
    "Water Consumption": water,
    "Weather Data": weather,
}

schema_frames = {}
for name, df in datasets.items():
    schema_frames[name] = schema_report(df, name)
============================================================
  NMI Consumption (campus electricity)
  Rows: 3,507,076  |  Columns: 6  |  Memory: 98.2 MB
  Date range: 2015-01-01 00:00:00 → 2022-04-30 00:00:00
============================================================
============================================================
  Building Consumption
  Rows: 8,095,524  |  Columns: 4  |  Memory: 161.9 MB
  Date range: 2018-01-01 00:15:00 → 2022-04-30 23:45:00
============================================================
============================================================
  Submeter Consumption
  Rows: 1,665,162  |  Columns: 9  |  Memory: 266.4 MB
============================================================
============================================================
  Gas Consumption
  Rows: 27,164  |  Columns: 3  |  Memory: 0.4 MB
  Date range: 2018-05-01 06:00:00 → 2021-12-29 06:00:00
============================================================
============================================================
  Water Consumption
  Rows: 245,040  |  Columns: 4  |  Memory: 4.9 MB
  Date range: 2021-01-10 00:00:00 → 2022-12-03 23:45:00
============================================================
============================================================
  Weather Data
  Rows: 7,396,520  |  Columns: 8  |  Memory: 266.3 MB
  Date range: 2018-01-01 00:00:00 → 2022-04-30 23:30:00
============================================================
Code
for name, info in schema_frames.items():
    print(f"\n--- {name} ---")
    display(info)

--- NMI Consumption (campus electricity) ---

--- Building Consumption ---

--- Submeter Consumption ---

--- Gas Consumption ---

--- Water Consumption ---

--- Weather Data ---
Table 7: Schema detail for all consumption and weather files
dtype non_null null_count null_pct
campus_id float32 3352909 154167 4.4
meter_id float32 3507076 0 0.0
timestamp datetime64[ns] 3507076 0 0.0
consumption float32 3507076 0 0.0
demand_kW float32 3507076 0 0.0
demand_kVA float32 3507076 0 0.0
dtype non_null null_count null_pct
campus_id float32 8095524 0 0.0
meter_id float32 8095524 0 0.0
timestamp datetime64[ns] 8095524 0 0.0
consumption float32 8095524 0 0.0
dtype non_null null_count null_pct
building_id float32 1312464 352698 21.18
id float32 1665162 0 0.00
campus_id float32 1312464 352698 21.18
timestamp object 1665162 0 0.00
consumption float32 1665162 0 0.00
current float32 1665162 0 0.00
voltage float32 1665162 0 0.00
power float32 1665162 0 0.00
power_factor float32 1665162 0 0.00
dtype non_null null_count null_pct
campus_id float32 27164 0 0.0
timestamp datetime64[ns] 27164 0 0.0
consumption float32 27164 0 0.0
dtype non_null null_count null_pct
campus_id float32 245040 0 0.00
meter_id float32 245040 0 0.00
timestamp datetime64[ns] 245040 0 0.00
consumption float32 244029 1011 0.41
dtype non_null null_count null_pct
campus_id float32 7396520 0 0.00
timestamp datetime64[ns] 7396520 0 0.00
apparent_temperature float32 7396520 0 0.00
air_temperature float32 7396520 0 0.00
dew_point_temperature float32 7396520 0 0.00
relative_humidity float32 7396520 0 0.00
wind_speed float32 7337253 59267 0.80
wind_direction float32 7336534 59986 0.81

Dataset Overview

Metering Hierarchy

The dataset follows a hierarchical metering structure:

University (La Trobe)
├── Campus (5 campuses)
│   ├── NMI Meters (14 primary meters — campus-level electricity)
│   ├── Building Meters (71 buildings — building-level electricity)
│   │   └── Submeters (9 submeters — panel-level electricity)
│   ├── Gas Meters (2 meters — Bundoora & Bendigo)
│   └── Water Meters (3 meters — Bundoora only)
└── Weather Stations (5 — one per campus)

Temporal Coverage

Code
temporal_data = []
for name, df in datasets.items():
    ts_col = "timestamp"
    if ts_col in df.columns and pd.api.types.is_datetime64_any_dtype(df[ts_col]):
        diffs = df[ts_col].sort_values().diff().dropna()
        median_freq = diffs.median()
        temporal_data.append({
            "Dataset": name,
            "Start": df[ts_col].min().strftime("%Y-%m-%d"),
            "End": df[ts_col].max().strftime("%Y-%m-%d"),
            "Records": f"{len(df):,}",
            "Median Interval": str(median_freq),
        })

pd.DataFrame(temporal_data)
Table 8: Temporal coverage and granularity for each data source
Dataset Start End Records Median Interval
0 NMI Consumption (campus electricity) 2015-01-01 2022-04-30 3,507,076 0 days 00:00:00
1 Building Consumption 2018-01-01 2022-04-30 8,095,524 0 days 00:00:00
2 Gas Consumption 2018-05-01 2021-12-29 27,164 0 days 01:00:00
3 Water Consumption 2021-01-10 2022-12-03 245,040 0 days 00:00:00
4 Weather Data 2018-01-01 2022-04-30 7,396,520 0 days 00:00:00

Descriptive Statistics

Code
building_consumption.groupby("campus_id")["consumption"].describe().round(2)
Table 9: Electricity consumption (building-level) — summary statistics by campus
count mean std min 25% 50% 75% max
campus_id
1.0 6841813.0 14.72 15.67 -22.80 4.38 10.12 19.12 116.88
2.0 868674.0 4.90 6.14 0.00 1.20 2.22 6.12 46.72
3.0 385037.0 2.45 1.46 0.42 1.26 2.32 3.27 9.05
Code
gas.groupby("campus_id")["consumption"].describe().round(2)
Table 10: Gas consumption — summary statistics by campus
count mean std min 25% 50% 75% max
campus_id
1.0 23111.0 25.01 6.87 0.69 19.93 24.17 29.10 47.09
3.0 4053.0 2.82 2.79 0.49 0.56 1.55 4.73 10.40
Code
water.groupby("campus_id")["consumption"].describe().round(2)
Table 11: Water consumption — summary statistics by campus
count mean std min 25% 50% 75% max
campus_id
1.0 244029.0 232.43 318.27 0.0 24.04 134.57 381.66 1903.33
Code
weather.describe().round(2)
Table 12: Weather data — summary statistics
campus_id timestamp apparent_temperature air_temperature dew_point_temperature relative_humidity wind_speed wind_direction
count 7396520.00 7396520 7396520.00 7396520.00 7396520.00 7396520.00 7337253.00 7336534.00
mean 2.48 2019-10-09 21:35:35.429479424 13.16 15.79 7.44 64.46 11.23 182.37
min 1.00 2018-01-01 00:00:00 -9.80 -3.10 -23.10 2.00 0.00 0.00
25% 2.00 2018-11-18 01:42:00 7.30 10.20 4.50 45.00 5.40 107.00
50% 2.00 2019-10-05 02:24:30 12.10 14.70 7.20 66.00 11.20 186.00
75% 3.00 2020-08-21 04:07:00 18.40 20.60 10.20 86.00 16.60 261.00
max 5.00 2022-04-30 23:30:00 43.20 46.60 25.80 100.00 87.10 359.00
std 1.11 NaN 7.93 7.86 4.49 25.07 7.70 98.65

Data Quality Assessment

Code
missing_data = []
for name, df in datasets.items():
    for col in df.columns:
        pct = df[col].isnull().sum() / len(df) * 100
        if pct > 0:
            missing_data.append({"Dataset": name, "Column": col, "Missing %": pct})

if missing_data:
    missing_df = pd.DataFrame(missing_data)
    fig, ax = plt.subplots(figsize=(10, max(4, len(missing_df) * 0.4)))
    missing_df = missing_df.sort_values("Missing %", ascending=True)
    labels = missing_df["Dataset"] + " → " + missing_df["Column"]
    ax.barh(range(len(missing_df)), missing_df["Missing %"],
            color=COLORS["accent"], edgecolor='#2d3748', height=0.6)
    ax.set_yticks(range(len(missing_df)))
    ax.set_yticklabels(labels, fontsize=9)
    ax.set_xlabel("Missing %", fontweight="bold")
    ax.set_title("Missing Values Across Datasets", fontweight="bold", pad=15)
    for i, v in enumerate(missing_df["Missing %"]):
        ax.text(v + 0.3, i, f"{v:.1f}%", va="center", color="#cbd5e1", fontsize=9)
    plt.tight_layout()
    plt.savefig("unicon-missing-values.png", dpi=150, bbox_inches="tight", facecolor="#1b2838")
else:
    print("No missing values found across any dataset.")
Figure 1: Missing value percentage across all datasets

Key Visualisations

Daily Electricity Consumption

Code
daily_elec = (
    building_consumption
    .assign(date=building_consumption["timestamp"].dt.date)
    .groupby(["campus_id", "date"])["consumption"]
    .sum()
    .reset_index()
)
daily_elec["date"] = pd.to_datetime(daily_elec["date"])

campus_names = campus_meta.set_index("id")["name"].to_dict()

fig, ax = plt.subplots(figsize=(14, 6))
campus_colors = ['#63b3ed', '#fc8181', '#68d391', '#f6ad55', '#b794f4']

for i, (campus_id, group) in enumerate(daily_elec.groupby("campus_id")):
    label = campus_names.get(campus_id, f"Campus {campus_id}")
    color = campus_colors[i % len(campus_colors)]
    # Resample to weekly for readability
    weekly = group.set_index("date")["consumption"].resample("W").mean()
    ax.plot(weekly.index, weekly.values, label=label, color=color, linewidth=1.2, alpha=0.85)

# Mark COVID-19 shutdown
ax.axvline(pd.Timestamp("2020-03-20"), color="#fc8181", linestyle="--", alpha=0.7, linewidth=1.5)
ax.text(pd.Timestamp("2020-04-01"), ax.get_ylim()[1] * 0.95, "COVID-19\nShutdown",
        color="#fc8181", fontsize=9, fontweight="bold", va="top")

ax.set_xlabel("Date", fontweight="bold")
ax.set_ylabel("Daily Consumption (kWh)", fontweight="bold")
ax.set_title("Weekly Average Electricity Consumption by Campus", fontweight="bold", pad=15)
ax.legend(loc="upper right", fontsize=9)
ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m"))
ax.xaxis.set_major_locator(mdates.MonthLocator(interval=3))
plt.xticks(rotation=45)
plt.tight_layout()
plt.savefig("unicon-daily-consumption.png", dpi=150, bbox_inches="tight", facecolor="#1b2838")
Figure 2: Daily total electricity consumption (building-level) by campus, 2018–2021

Consumption Distributions by Utility

Code
fig, axes = plt.subplots(1, 3, figsize=(14, 5))

# Electricity (building-level)
axes[0].hist(building_consumption["consumption"].dropna().clip(upper=building_consumption["consumption"].quantile(0.99)),
             bins=80, color=COLORS["electricity"], edgecolor="#2d3748", alpha=0.85)
axes[0].set_title("Electricity (kWh)", fontweight="bold")
axes[0].set_xlabel("Consumption (kWh)")
axes[0].set_ylabel("Frequency")
axes[0].set_yscale("log")

# Gas
axes[1].hist(gas["consumption"].dropna().clip(upper=gas["consumption"].quantile(0.99)),
             bins=60, color=COLORS["gas"], edgecolor="#2d3748", alpha=0.85)
axes[1].set_title("Gas (kJ)", fontweight="bold")
axes[1].set_xlabel("Consumption (kJ)")
axes[1].set_yscale("log")

# Water
axes[2].hist(water["consumption"].dropna().clip(upper=water["consumption"].quantile(0.99)),
             bins=60, color=COLORS["water"], edgecolor="#2d3748", alpha=0.85)
axes[2].set_title("Water (kL)", fontweight="bold")
axes[2].set_xlabel("Consumption (kL)")
axes[2].set_yscale("log")

plt.suptitle("Consumption Distributions (clipped at 99th percentile)", fontweight="bold", y=1.02)
plt.tight_layout()
plt.savefig("unicon-distributions.png", dpi=150, bbox_inches="tight", facecolor="#1b2838")
Figure 3: Consumption distributions by utility type (log scale)

Weather vs Electricity Correlation

Code
# Aggregate building consumption to daily per campus
daily_elec_campus = (
    building_consumption[building_consumption["campus_id"] == 1]
    .assign(date=building_consumption.loc[building_consumption["campus_id"] == 1, "timestamp"].dt.date)
    .groupby("date")["consumption"]
    .sum()
    .reset_index()
)
daily_elec_campus["date"] = pd.to_datetime(daily_elec_campus["date"])

# Aggregate weather to daily
daily_weather = (
    weather[weather["campus_id"] == 1]
    .assign(date=weather.loc[weather["campus_id"] == 1, "timestamp"].dt.date)
    .groupby("date")[["air_temperature", "apparent_temperature", "relative_humidity", "wind_speed"]]
    .mean()
    .reset_index()
)
daily_weather["date"] = pd.to_datetime(daily_weather["date"])

# Merge
merged = daily_elec_campus.merge(daily_weather, on="date", how="inner")

# Correlation matrix
weather_cols = ["consumption", "air_temperature", "apparent_temperature", "relative_humidity", "wind_speed"]
corr = merged[weather_cols].corr()

fig, ax = plt.subplots(figsize=(8, 6))
mask = np.triu(np.ones_like(corr, dtype=bool))
cmap = sns.diverging_palette(220, 20, as_cmap=True)

labels = ["Electricity\n(kWh)", "Air Temp\n(°C)", "Apparent\nTemp (°C)", "Humidity\n(%)", "Wind Speed\n(m/s)"]

sns.heatmap(
    corr, mask=mask, annot=True, fmt=".2f", cmap=cmap,
    center=0, square=True, linewidths=2, linecolor="#2d3748",
    cbar_kws={"shrink": 0.8, "label": "Correlation"},
    ax=ax, vmin=-1, vmax=1,
    annot_kws={"size": 12, "weight": "bold"},
    xticklabels=labels, yticklabels=labels,
)
ax.set_title("Weather–Electricity Correlation (Bundoora)", fontweight="bold", pad=15)
plt.tight_layout()
plt.savefig("unicon-weather-correlation.png", dpi=150, bbox_inches="tight", facecolor="#1b2838")
Figure 4: Correlation between weather features and daily electricity consumption (Bundoora campus)

Temperature vs Consumption Scatter

Code
fig, ax = plt.subplots(figsize=(10, 6))
merged["year"] = merged["date"].dt.year
year_colors = {2018: '#63b3ed', 2019: '#68d391', 2020: '#fc8181', 2021: '#f6ad55'}

for year, group in merged.groupby("year"):
    ax.scatter(
        group["air_temperature"], group["consumption"],
        color=year_colors.get(year, '#4a5568'), label=str(year),
        alpha=0.5, s=15, edgecolor="none"
    )

ax.set_xlabel("Mean Daily Air Temperature (°C)", fontweight="bold")
ax.set_ylabel("Daily Electricity Consumption (kWh)", fontweight="bold")
ax.set_title("Temperature vs Electricity Consumption", fontweight="bold", pad=15)
ax.legend(title="Year", fontsize=9)
plt.tight_layout()
plt.savefig("unicon-temp-scatter.png", dpi=150, bbox_inches="tight", facecolor="#1b2838")
Figure 5: Daily electricity consumption vs air temperature — Bundoora campus (coloured by year)

Summary

Aspect Finding
Dataset scope 5 campuses, 71 buildings, 14 NMIs, 3 utilities (electricity, gas, water)
Time span 2018–2021 (4 years), capturing the full COVID-19 period
Temporal resolution Electricity: 15-min, Gas: hourly, Water: daily
Total records ~950 MB across 11 CSV files
COVID-19 impact Clear consumption drop after March 2020 shutdown — natural experiment for causal inference
Seasonal patterns Strong seasonal cycles in electricity and gas; electricity correlates with temperature
Weather correlation Air temperature shows meaningful correlation with daily electricity consumption
Building diversity 8 categories (teaching, library, office, residence, mixed use, sport, other, leased)
ECM events Annotated LED retrofits and HVAC upgrades enable intervention impact analysis

Next Steps: Probabilistic Modelling

This dataset is well-suited for several probabilistic modelling approaches:

  1. Gaussian Process regression for building-level consumption forecasting with uncertainty quantification
  2. Bayesian structural time series for causal impact analysis of COVID-19 and ECM interventions
  3. Hierarchical models exploiting the campus → building → submeter structure
  4. Normalizing flows or VAEs for learning multi-modal consumption distributions
  5. Change-point detection for identifying consumption regime shifts

Dataset source: UNICON — An Open Dataset of Electricity, Gas and Water Consumption, La Trobe University, Australia.


Probabilistic Modelling Framework

The EDA above reveals rich temporal structure — seasonal cycles, weekly occupancy patterns, weather sensitivity, and abrupt regime shifts from COVID-19 and energy conservation measures (ECMs). A natural next step is to build a predictive model that decomposes these patterns into interpretable components while providing calibrated uncertainty estimates.

Gaussian Processes (GPs) are ideally suited for this task. Unlike point-estimate models (XGBoost, linear regression), a GP defines a distribution over functions, yielding both a mean prediction and a confidence band at every point. Formally:

\[f(\mathbf{x}) \sim \mathcal{GP}\big(m(\mathbf{x}),\; k(\mathbf{x}, \mathbf{x}')\big)\]

where \(m(\mathbf{x})\) is the mean function and \(k(\mathbf{x}, \mathbf{x}')\) is the kernel (covariance function) encoding our structural assumptions about how energy consumption behaves.

Compositional Kernel Design

The key insight from the Automatic Statistician programme (Duvenaud et al., 2013; Lloyd et al., 2014) is that complex time series can be decomposed into interpretable components through additive kernel composition. If \(k = k_1 + k_2 + \cdots\), then the GP decomposes into independent additive functions \(f = f_1 + f_2 + \cdots\), each capturing a distinct physical mechanism.

We design a 6-component additive kernel for building energy consumption:

\[k_{\text{total}}(\mathbf{x}, \mathbf{x}') = k_{\text{trend}} + k_{\text{annual}} + k_{\text{weekly}} + k_{\text{weather}} + k_{\text{calendar}} + k_{\text{event}}\]

Component Kernel Type Formula Physical Mechanism
Long-term trend ScaleKernel(RBF) \(\sigma_{\text{tr}}^2 \exp\!\bigl(-\frac{(t-t')^2}{2\ell_{\text{tr}}^2}\bigr)\) Gradual baseline drift (efficiency degradation, retrofits)
Annual seasonality ScaleKernel(Periodic) \(\sigma_a^2 \exp\!\bigl(-\frac{2\sin^2(\pi(t-t')/365.25)}{\ell_a^2}\bigr)\) Climate-driven HVAC cycles (heating in winter, cooling in summer)
Weekly periodicity ScaleKernel(Periodic) \(\sigma_w^2 \exp\!\bigl(-\frac{2\sin^2(\pi(t-t')/7)}{\ell_w^2}\bigr)\) Occupancy cycle (weekday operations vs weekend baseload)
Weather response ScaleKernel(RBF-ARD) \(\sigma_{\text{wth}}^2 \exp\!\bigl(-\frac{1}{2}(\mathbf{w}-\mathbf{w}')^\top\!\Lambda^{-1}(\mathbf{w}-\mathbf{w}')\bigr)\) Nonlinear temperature–consumption relationship
Calendar effects ScaleKernel(RBF-ARD) ARD over (semester, holiday, exam, weekend) Academic calendar modulation
ECM/COVID events ScaleKernel(Linear) \(\sigma_e^2\,\mathbf{e}^\top\mathbf{e}'\) Step-change interventions (HVAC upgrades, LED retrofits, lockdowns)

Predictive Distribution and Training

Given \(n\) training observations \((\mathbf{X}, \mathbf{y})\), the GP posterior at a test point \(\mathbf{x}_*\) is:

\[\bar{f}_* = \mathbf{k}_*^\top (K_{XX} + \sigma_n^2 I)^{-1}\mathbf{y}, \qquad \text{Var}(f_*) = k(\mathbf{x}_*, \mathbf{x}_*) - \mathbf{k}_*^\top (K_{XX} + \sigma_n^2 I)^{-1}\mathbf{k}_*\]

Hyperparameters (lengthscales, variances, periods) are learned by maximising the log-marginal likelihood, which naturally balances data fit against model complexity (Occam’s razor):

\[\log p(\mathbf{y} \mid X, \boldsymbol{\theta}) = -\tfrac{1}{2}\mathbf{y}^\top(K + \sigma_n^2 I)^{-1}\mathbf{y} - \tfrac{1}{2}\log|K + \sigma_n^2 I| - \tfrac{n}{2}\log 2\pi\]

Kernel Decomposition for Interpretability

Because our kernel is additive, the posterior decomposes into per-component contributions:

\[\bar{f}_c(\mathbf{x}_*) = K_c(\mathbf{x}_*, X)\,(K(X,X) + \sigma_n^2 I)^{-1}\,\mathbf{y}\]

This allows us to explain what drives consumption at any point: “on this day, the trend contributes +50 kWh, the weekly cycle −120 kWh (it’s Sunday), and the weather adds +80 kWh (heatwave).”

Anomaly Detection

The GP’s calibrated uncertainty provides a principled anomaly score:

\[z(\mathbf{x}_*) = \frac{|y_{\text{obs}} - \bar{f}_*|}{\sqrt{\text{Var}(f_*) + \sigma_n^2}}\]

Under the model, \(z \sim \mathcal{N}(0,1)\). Days with \(|z| > 2.5\) (expected false positive rate ≈ 1.2%) are flagged as anomalous — contextually, accounting for time-of-week, season, and weather.

Data Preparation for GP Modelling

We select one representative building per category from the 8 building types in the UNICON dataset. This enables direct comparison of how the GP captures fundamentally different consumption dynamics across building functions.

Design choices:

  • Daily aggregation — reduces each building from ~140,000 rows (15-min) to ~1,500 rows (daily), making ExactGP tractable (\(\mathcal{O}(n^3) \approx 15\) seconds per building)
  • Temporal train/test split — train on 2018–2019 (pre-COVID), test on 2020–2022 (includes COVID, ECMs). This tests true out-of-distribution generalisation
  • 15 features — 6 weather + 4 calendar + 2 temporal + 3 event indicators
Code
# ─── Select one representative building per category ───
# Criteria: Bundoora campus preferred, best data completeness, meaningful floor area

building_categories = building_meta.groupby("category")["id"].apply(list).to_dict()
print("Building categories in dataset:")
for cat, ids in building_categories.items():
    print(f"  {cat}: {len(ids)} buildings — IDs: {ids}")

# For each category, pick the building with the most data points in building_consumption
category_candidates = {}
for cat, ids in building_categories.items():
    best_id, best_count = None, 0
    for bid in ids:
        count = (building_consumption["meter_id"] == bid).sum()
        if count > best_count:
            best_count = count
            best_id = bid
    category_candidates[cat] = {"building_id": best_id, "record_count": best_count}

# Build selection table
selection_rows = []
for cat, info in category_candidates.items():
    bid = info["building_id"]
    meta_row = building_meta[building_meta["id"] == bid].iloc[0] if bid in building_meta["id"].values else None
    bc_subset = building_consumption[building_consumption["meter_id"] == bid]
    date_min = bc_subset["timestamp"].min()
    date_max = bc_subset["timestamp"].max()
    n_days = bc_subset["timestamp"].dt.date.nunique()
    # Count events for this building
    n_events = len(events[events["meter_id"] == bid]) if "meter_id" in events.columns else 0
    selection_rows.append({
        "Category": cat,
        "Building ID": int(bid) if bid is not None else "N/A",
        "Campus": int(meta_row["campus_id"]) if meta_row is not None else "N/A",
        "Floor Area (ft²)": f"{int(meta_row['gross_floor_area']):,}" if meta_row is not None and pd.notna(meta_row.get("gross_floor_area")) else "N/A",
        "Records": f"{info['record_count']:,}",
        "Days": n_days,
        "Date Range": f"{date_min.strftime('%Y-%m-%d')} → {date_max.strftime('%Y-%m-%d')}",
        "Events": n_events,
    })

selection_df = pd.DataFrame(selection_rows).sort_values("Category")
selection_df
Building categories in dataset:
  leased: 1 buildings — IDs: [19]
  library: 2 buildings — IDs: [8, 37]
  mixed use: 26 buildings — IDs: [4, 14, 16, 17, 18, 22, 24, 25, 26, 27, 29, 30, 33, 34, 36, 38, 44, 50, 51, 54, 55, 58, 59, 60, 61, 63]
  office: 3 buildings — IDs: [23, 42, 49]
  other: 14 buildings — IDs: [1, 2, 3, 5, 6, 12, 15, 21, 28, 40, 43, 45, 52, 64]
  residence: 5 buildings — IDs: [20, 46, 47, 48, 53]
  sport: 2 buildings — IDs: [35, 56]
  teaching: 11 buildings — IDs: [7, 9, 10, 11, 13, 31, 32, 39, 41, 57, 62]
Representative buildings selected for GP modelling — one per category
Category Building ID Campus Floor Area (ft²) Records Days Date Range Events
0 leased 19 1 600 141,367 1576 2018-01-01 → 2022-04-30 1
1 library 37 1 5,459,748 146,905 1576 2018-01-01 → 2022-04-30 3
2 mixed use 4 1 145,558 148,288 1576 2018-01-01 → 2022-04-30 1
3 office 23 1 558,322 148,127 1576 2018-01-01 → 2022-04-30 1
4 other 15 1 15,728 147,380 1575 2018-01-01 → 2022-04-30 1
5 residence 20 1 42,646 146,588 1576 2018-01-01 → 2022-04-30 1
6 sport 35 1 316,182 146,996 1574 2018-01-01 → 2022-04-30 3
7 teaching 62 1 1,274,716 147,330 1576 2018-01-01 → 2022-04-30 3
Code
# ─── Aggregate to daily and build feature matrix for each building ───
selected_buildings = {row["Category"]: row["Building ID"] for _, row in selection_df.iterrows()}

def prepare_building_data(bid, weather_df, calendar_df, events_df, building_consumption_df):
    """Aggregate a single building to daily resolution and create feature matrix."""
    bc = building_consumption_df[building_consumption_df["meter_id"] == bid].copy()
    bc["date"] = bc["timestamp"].dt.date

    # Daily aggregation: sum consumption, count readings
    daily = (
        bc.groupby("date")["consumption"]
        .agg(["sum", "count"])
        .rename(columns={"sum": "consumption_kwh", "count": "readings_count"})
        .reset_index()
    )
    daily["date"] = pd.to_datetime(daily["date"])
    # Filter incomplete days (need >= 90 of 96 possible 15-min readings)
    daily = daily[daily["readings_count"] >= 90].copy()

    # Weather: aggregate Bundoora (campus_id=1) to daily
    w = weather_df[weather_df["campus_id"] == 1].copy()
    w["date"] = w["timestamp"].dt.date
    daily_w = (
        w.groupby("date")
        .agg(
            temp_mean=("air_temperature", "mean"),
            temp_min=("air_temperature", "min"),
            temp_max=("air_temperature", "max"),
            temp_range=("air_temperature", lambda x: x.max() - x.min()),
            humidity_mean=("relative_humidity", "mean"),
            wind_speed_mean=("wind_speed", "mean"),
        )
        .reset_index()
    )
    daily_w["date"] = pd.to_datetime(daily_w["date"])

    # Merge weather
    merged = daily.merge(daily_w, on="date", how="inner")

    # Calendar features
    cal = calendar_df.copy()
    cal["date"] = pd.to_datetime(cal["date"])
    merged = merged.merge(cal[["date", "is_holiday", "is_semester", "is_exam"]], on="date", how="left")

    # Temporal features
    merged["day_of_week"] = merged["date"].dt.dayofweek
    merged["day_of_year"] = merged["date"].dt.dayofyear
    merged["is_weekend"] = (merged["day_of_week"] >= 5).astype(float)

    # Event features: binary step indicators
    bld_events = events_df[events_df["meter_id"] == bid] if "meter_id" in events_df.columns else pd.DataFrame()
    # Initialise all event columns to 0
    merged["post_event_1"] = 0.0
    merged["post_event_2"] = 0.0
    merged["post_event_3"] = 0.0

    if len(bld_events) > 0:
        evt_dates = sorted(bld_events["date"].values)
        for i, evt_date in enumerate(evt_dates[:3]):
            col = f"post_event_{i+1}"
            evt_date_ts = pd.to_datetime(evt_date)
            merged[col] = (merged["date"] >= evt_date_ts).astype(float)

    # Drop NaN rows
    merged = merged.dropna().reset_index(drop=True)

    return merged

# Prepare all buildings (one per category)
building_data = {}
for cat, bid in selected_buildings.items():
    building_data[cat] = prepare_building_data(
        bid, weather, calendar, events, building_consumption
    )
    n = len(building_data[cat])
    print(f"  {cat:20s} (Building {int(bid):3d}): {n} daily observations")

print(f"\nTotal buildings prepared: {len(building_data)}")
Code
# ─── Feature definitions ───
weather_features = ["temp_mean", "temp_min", "temp_max", "temp_range",
                    "humidity_mean", "wind_speed_mean"]
calendar_features = ["is_holiday", "is_semester", "is_exam", "is_weekend"]
temporal_features = ["day_of_week", "day_of_year"]
event_features = ["post_event_1", "post_event_2", "post_event_3"]
all_features = weather_features + calendar_features + temporal_features + event_features

# ─── Train/test split + standardisation for each building ───
SPLIT_DATE = pd.Timestamp("2020-01-01")
gp_datasets = {}

split_summary = []
for cat, df in building_data.items():
    train_df = df[df["date"] < SPLIT_DATE].copy()
    test_df = df[df["date"] >= SPLIT_DATE].copy()

    # Standardise features (fit on train only)
    feat_scaler = StandardScaler()
    tgt_scaler = StandardScaler()

    X_train = feat_scaler.fit_transform(train_df[all_features].values)
    X_test = feat_scaler.transform(test_df[all_features].values)
    y_train = tgt_scaler.fit_transform(train_df[["consumption_kwh"]].values).ravel()
    y_test = tgt_scaler.transform(test_df[["consumption_kwh"]].values).ravel()

    # Convert to tensors
    train_x = torch.tensor(X_train, dtype=torch.float32)
    train_y = torch.tensor(y_train, dtype=torch.float32)
    test_x = torch.tensor(X_test, dtype=torch.float32)
    test_y = torch.tensor(y_test, dtype=torch.float32)

    gp_datasets[cat] = {
        "train_x": train_x, "train_y": train_y,
        "test_x": test_x, "test_y": test_y,
        "train_df": train_df, "test_df": test_df,
        "feat_scaler": feat_scaler, "tgt_scaler": tgt_scaler,
        "building_id": selected_buildings[cat],
    }

    split_summary.append({
        "Category": cat,
        "Building": int(selected_buildings[cat]),
        "Train days": len(train_df),
        "Test days": len(test_df),
        "Train range": f"{train_df['date'].min().strftime('%Y-%m-%d')} → {train_df['date'].max().strftime('%Y-%m-%d')}",
        "Test range": f"{test_df['date'].min().strftime('%Y-%m-%d')} → {test_df['date'].max().strftime('%Y-%m-%d')}",
        "Mean kWh/day": f"{df['consumption_kwh'].mean():.0f}",
    })

pd.DataFrame(split_summary).sort_values("Category")
Train/test split summary — pre-COVID training, COVID-era testing
Category Building Train days Test days Train range Test range Mean kWh/day
0 leased 19 455 388 2018-01-08 → 2019-12-23 2020-01-06 → 2021-06-30 318
1 library 37 668 477 2018-01-01 → 2019-12-31 2020-01-01 → 2021-06-30 6980
2 mixed use 4 669 474 2018-01-01 → 2019-12-31 2020-01-01 → 2021-06-28 585
3 office 23 658 471 2018-01-01 → 2019-12-31 2020-01-01 → 2021-06-28 3280
4 other 15 692 456 2018-01-01 → 2019-12-31 2020-01-01 → 2021-06-30 1390
5 residence 20 628 443 2018-01-01 → 2019-12-31 2020-01-01 → 2021-06-30 2439
6 sport 35 609 472 2018-01-01 → 2019-12-25 2020-01-05 → 2021-06-30 1205
7 teaching 62 631 481 2018-01-01 → 2019-12-31 2020-01-01 → 2021-06-30 1093

Single-Building GP Model

We define the EnergyGP model class with our 6-component additive kernel and train it on all 8 buildings. Each building learns its own hyperparameters, enabling direct comparison of how different building types structure their consumption.

Code
# ─── Feature dimension indices (after standardisation) ───
weather_dims = list(range(0, 6))     # temp_mean ... wind_speed
calendar_dims = list(range(6, 10))   # is_holiday, is_semester, is_exam, is_weekend
dow_dim = [10]                       # day_of_week
doy_dim = [11]                       # day_of_year
event_dims = list(range(12, 15))     # post_event_1, post_event_2, post_event_3

class EnergyGP(ExactGP):
    """Compositional GP for building energy consumption.

    Additive kernel with 6 components, each capturing a distinct
    physical mechanism in building energy dynamics.
    """

    def __init__(self, train_x, train_y, likelihood):
        super().__init__(train_x, train_y, likelihood)
        self.mean_module = ConstantMean()

        # Component 1: Long-term trend (RBF over day-of-year)
        self.k_trend = ScaleKernel(RBFKernel(active_dims=doy_dim))

        # Component 2: Annual seasonality (Periodic, P ≈ 365 days)
        self.k_annual = ScaleKernel(PeriodicKernel(active_dims=doy_dim))

        # Component 3: Weekly periodicity (Periodic, P ≈ 7 days)
        self.k_weekly = ScaleKernel(PeriodicKernel(active_dims=dow_dim))

        # Component 4: Weather response (ARD-RBF over 6 weather features)
        self.k_weather = ScaleKernel(
            RBFKernel(ard_num_dims=len(weather_dims), active_dims=weather_dims)
        )

        # Component 5: Calendar effects (ARD-RBF over academic calendar)
        self.k_calendar = ScaleKernel(
            RBFKernel(ard_num_dims=len(calendar_dims), active_dims=calendar_dims)
        )

        # Component 6: Event step-changes (Linear kernel on binary indicators)
        self.k_event = ScaleKernel(LinearKernel(active_dims=event_dims))

    def forward(self, x):
        mean = self.mean_module(x)
        covar = (
            self.k_trend(x) + self.k_annual(x) + self.k_weekly(x)
            + self.k_weather(x) + self.k_calendar(x) + self.k_event(x)
        )
        return gpytorch.distributions.MultivariateNormal(mean, covar)

print("EnergyGP model defined with 6 additive kernel components:")
print("  k_total = k_trend + k_annual + k_weekly + k_weather + k_calendar + k_event")
EnergyGP model defined with 6 additive kernel components:
  k_total = k_trend + k_annual + k_weekly + k_weather + k_calendar + k_event
Code
def train_gp(train_x, train_y, n_iter=300, lr=0.1, verbose=False):
    """Train an EnergyGP model and return model + likelihood."""
    likelihood = GaussianLikelihood()
    model = EnergyGP(train_x, train_y, likelihood)

    model.train()
    likelihood.train()

    optimizer = torch.optim.Adam(model.parameters(), lr=lr)
    mll = ExactMarginalLogLikelihood(likelihood, model)

    losses = []
    for i in range(n_iter):
        optimizer.zero_grad()
        output = model(train_x)
        loss = -mll(output, train_y)
        loss.backward()
        optimizer.step()
        losses.append(loss.item())

        if verbose and (i + 1) % 100 == 0:
            noise = likelihood.noise.item()
            print(f"  Iter {i+1:3d}/{n_iter} | Loss: {loss.item():.3f} | Noise: {noise:.4f}")

    model.eval()
    likelihood.eval()
    return model, likelihood, losses

# ─── Train all 7 models ───
gp_models = {}
all_losses = {}

for cat in sorted(gp_datasets.keys()):
    ds = gp_datasets[cat]
    bid = ds["building_id"]
    print(f"Training {cat} (Building {int(bid)})...", end=" ")
    model, likelihood, losses = train_gp(ds["train_x"], ds["train_y"], n_iter=300, lr=0.1)
    gp_models[cat] = {"model": model, "likelihood": likelihood}
    all_losses[cat] = losses
    print(f"Done. Final loss: {losses[-1]:.3f}")

# Plot convergence for all models
fig, ax = plt.subplots(figsize=(12, 5))
cat_colors = {
    "teaching": "#63b3ed", "library": "#fc8181", "office": "#68d391",
    "residence": "#f6ad55", "mixed use": "#b794f4", "sport": "#f687b3",
    "other": "#4a5568", "leased": "#d6bcfa"
}

for cat, losses in all_losses.items():
    color = cat_colors.get(cat, "#cbd5e1")
    ax.plot(losses, label=cat, color=color, linewidth=1.2, alpha=0.85)

ax.set_xlabel("Iteration", fontweight="bold")
ax.set_ylabel("Negative Log-Marginal Likelihood", fontweight="bold")
ax.set_title("GP Training Convergence — All Building Categories", fontweight="bold", pad=15)
ax.legend(fontsize=9, loc="upper right")
plt.tight_layout()
plt.savefig("unicon-gp-training.png", dpi=150, bbox_inches="tight", facecolor="#1b2838")
Training leased (Building 19)... Done. Final loss: 0.646
Training library (Building 37)... Done. Final loss: 1.184
Training mixed use (Building 4)... Done. Final loss: 0.599
Training office (Building 23)... Done. Final loss: 1.000
Training other (Building 15)... Done. Final loss: 0.513
Training residence (Building 20)... Done. Final loss: 0.693
Training sport (Building 35)... Done. Final loss: 0.926
Training teaching (Building 62)... Done. Final loss: 0.625

GP training convergence — negative log-marginal likelihood across iterations
Code
# ─── Generate predictions for all buildings ───
gp_predictions = {}

for cat in sorted(gp_datasets.keys()):
    ds = gp_datasets[cat]
    mdl = gp_models[cat]["model"]
    lik = gp_models[cat]["likelihood"]
    tgt_scaler = ds["tgt_scaler"]

    with torch.no_grad(), gpytorch.settings.fast_pred_var():
        # Train predictions
        train_pred = lik(mdl(ds["train_x"]))
        train_mean = train_pred.mean.numpy()
        train_var = train_pred.variance.numpy()

        # Test predictions
        test_pred = lik(mdl(ds["test_x"]))
        test_mean = test_pred.mean.numpy()
        test_var = test_pred.variance.numpy()

    # Inverse transform to original scale
    train_mean_orig = tgt_scaler.inverse_transform(train_mean.reshape(-1, 1)).ravel()
    test_mean_orig = tgt_scaler.inverse_transform(test_mean.reshape(-1, 1)).ravel()
    train_std_orig = np.sqrt(train_var) * tgt_scaler.scale_[0]
    test_std_orig = np.sqrt(test_var) * tgt_scaler.scale_[0]

    y_train_orig = tgt_scaler.inverse_transform(ds["train_y"].numpy().reshape(-1, 1)).ravel()
    y_test_orig = tgt_scaler.inverse_transform(ds["test_y"].numpy().reshape(-1, 1)).ravel()

    gp_predictions[cat] = {
        "train_dates": ds["train_df"]["date"].values,
        "test_dates": ds["test_df"]["date"].values,
        "train_mean": train_mean_orig, "test_mean": test_mean_orig,
        "train_std": train_std_orig, "test_std": test_std_orig,
        "y_train": y_train_orig, "y_test": y_test_orig,
        "train_mean_std": train_mean, "test_mean_std": test_mean,
        "train_var_std": train_var, "test_var_std": test_var,
    }

# ─── Plot primary building (Teaching) ───
primary_cat = "teaching"
pred = gp_predictions[primary_cat]
bid = gp_datasets[primary_cat]["building_id"]

fig, ax = plt.subplots(figsize=(14, 6))

# Training period
ax.scatter(pred["train_dates"], pred["y_train"],
           color=COLORS["muted"], s=3, alpha=0.3, label="Observed (train)", zorder=1)
ax.plot(pred["train_dates"], pred["train_mean"],
        color=COLORS["electricity"], linewidth=1.5, label="GP Mean", zorder=3)
ax.fill_between(pred["train_dates"],
                pred["train_mean"] - 1.96 * pred["train_std"],
                pred["train_mean"] + 1.96 * pred["train_std"],
                alpha=0.2, color=COLORS["electricity"], label="95% CI", zorder=2)

# Test period
ax.scatter(pred["test_dates"], pred["y_test"],
           color=COLORS["accent"], s=3, alpha=0.3, label="Observed (test)", zorder=1)
ax.plot(pred["test_dates"], pred["test_mean"],
        color=COLORS["electricity"], linewidth=1.5, zorder=3)
ax.fill_between(pred["test_dates"],
                pred["test_mean"] - 1.96 * pred["test_std"],
                pred["test_mean"] + 1.96 * pred["test_std"],
                alpha=0.15, color=COLORS["electricity"], zorder=2)

# Mark split, COVID, and events
ax.axvline(pd.Timestamp("2020-01-01"), color="#cbd5e1", linestyle=":", alpha=0.7, linewidth=1.5)
ax.text(pd.Timestamp("2020-01-15"), ax.get_ylim()[1] * 0.97, "Train | Test",
        color="#cbd5e1", fontsize=9, va="top")

ax.axvline(pd.Timestamp("2020-03-20"), color="#fc8181", linestyle="--", alpha=0.7, linewidth=1.5)
ax.text(pd.Timestamp("2020-04-01"), ax.get_ylim()[1] * 0.90, "COVID-19\nShutdown",
        color="#fc8181", fontsize=8, fontweight="bold", va="top")

ax.set_xlabel("Date", fontweight="bold")
ax.set_ylabel("Daily Consumption (kWh)", fontweight="bold")
ax.set_title(f"GP Posterior — {primary_cat} Building (ID {int(bid)})", fontweight="bold", pad=15)
ax.legend(loc="upper left", fontsize=9)
plt.tight_layout()
plt.savefig("unicon-gp-posterior.png", dpi=150, bbox_inches="tight", facecolor="#1b2838")
Figure 6: GP posterior prediction with 95% confidence interval — primary Teaching building (Building 62). Trained on 2018–2019, tested on 2020–2022 including COVID-19 shutdown and LED retrofit.

Kernel Decomposition Analysis

The additive kernel structure allows us to decompose the GP prediction into independent components, each with a clear physical interpretation. This is the key advantage over black-box models — we can explain why the model predicts what it does.

Code
def decompose_gp(model, likelihood, train_x, train_y, pred_x):
    """Extract additive kernel component contributions."""
    model.eval()
    likelihood.eval()
    with torch.no_grad():
        # Full covariance K + sigma_n^2 I (dense)
        output = model(train_x)
        K_noisy = likelihood(output).covariance_matrix
        residual = (train_y - output.mean).unsqueeze(-1)
        alpha = torch.linalg.solve(K_noisy, residual).squeeze(-1)

        components = {}
        kernels = {
            "Trend": model.k_trend,
            "Annual": model.k_annual,
            "Weekly": model.k_weekly,
            "Weather": model.k_weather,
            "Calendar": model.k_calendar,
            "Events": model.k_event,
        }

        for name, kernel in kernels.items():
            K_cross = kernel(pred_x, train_x).evaluate()
            f_component = (K_cross @ alpha).numpy()
            components[name] = f_component

    return components

# Decompose on training data for primary building
primary_ds = gp_datasets[primary_cat]
primary_mdl = gp_models[primary_cat]["model"]
primary_lik = gp_models[primary_cat]["likelihood"]

# Decompose on full date range (train + test)
all_x = torch.cat([primary_ds["train_x"], primary_ds["test_x"]])
all_dates = np.concatenate([pred["train_dates"], pred["test_dates"]])

decomp = decompose_gp(primary_mdl, primary_lik, primary_ds["train_x"], primary_ds["train_y"], all_x)

# Inverse-transform components to original scale
tgt_scale = primary_ds["tgt_scaler"].scale_[0]
tgt_mean_val = primary_ds["tgt_scaler"].mean_[0]

comp_colors = {
    "Trend": "#63b3ed", "Annual": "#fc8181", "Weekly": "#68d391",
    "Weather": "#f6ad55", "Calendar": "#b794f4", "Events": "#f687b3"
}

fig, axes = plt.subplots(6, 1, figsize=(14, 20), sharex=True)

for ax, (name, values) in zip(axes, decomp.items()):
    values_orig = values * tgt_scale
    ax.plot(all_dates, values_orig, color=comp_colors[name], linewidth=1.0, alpha=0.85)
    ax.fill_between(all_dates, 0, values_orig, alpha=0.15, color=comp_colors[name])
    ax.axhline(0, color="#4a5568", linestyle=":", alpha=0.5, linewidth=0.8)
    ax.axvline(pd.Timestamp("2020-01-01"), color="#cbd5e1", linestyle=":", alpha=0.4)
    ax.axvline(pd.Timestamp("2020-03-20"), color="#fc8181", linestyle="--", alpha=0.4)
    ax.set_ylabel(f"{name}\n(kWh)", fontweight="bold", fontsize=10)
    ax.tick_params(labelsize=9)

axes[0].set_title(f"Kernel Decomposition — {primary_cat} Building (ID {int(bid)})",
                   fontweight="bold", pad=15, fontsize=13)
axes[-1].set_xlabel("Date", fontweight="bold")
plt.tight_layout()
plt.savefig("unicon-gp-decomposition.png", dpi=150, bbox_inches="tight", facecolor="#1b2838")
Figure 7: Additive kernel decomposition for Teaching building — each panel shows one component’s contribution to total consumption
Code
# ─── Extract periodic patterns by averaging decomposition ───
all_dates_ts = pd.to_datetime(all_dates)

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))

# Weekly pattern
weekly_df = pd.DataFrame({
    "dow": all_dates_ts.dayofweek,
    "weekly": decomp["Weekly"] * tgt_scale
})
weekly_avg = weekly_df.groupby("dow")["weekly"].mean()
weekly_std = weekly_df.groupby("dow")["weekly"].std()
days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
bars = ax1.bar(days, weekly_avg.values, color=COLORS["electricity"],
               edgecolor="#2d3748", alpha=0.85, yerr=weekly_std.values,
               capsize=3, error_kw={"color": "#94a3b8", "linewidth": 1})
ax1.axhline(0, color="#4a5568", linestyle=":", alpha=0.5)
ax1.set_ylabel("Weekly Component (kWh)", fontweight="bold")
ax1.set_title("Learned Weekly Occupancy Pattern", fontweight="bold", pad=10)

# Annual pattern
annual_df = pd.DataFrame({
    "month": all_dates_ts.month,
    "annual": decomp["Annual"] * tgt_scale
})
annual_avg = annual_df.groupby("month")["annual"].mean()
annual_std = annual_df.groupby("month")["annual"].std()
months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
          "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
ax2.plot(range(1, 13), annual_avg.values, color="#fc8181", linewidth=2.5,
         marker="o", markersize=6, markeredgecolor="#2d3748")
ax2.fill_between(range(1, 13),
                 annual_avg.values - annual_std.values,
                 annual_avg.values + annual_std.values,
                 alpha=0.2, color="#fc8181")
ax2.axhline(0, color="#4a5568", linestyle=":", alpha=0.5)
ax2.set_xticks(range(1, 13))
ax2.set_xticklabels(months, fontsize=9)
ax2.set_ylabel("Annual Component (kWh)", fontweight="bold")
ax2.set_title("Learned Annual Seasonality", fontweight="bold", pad=10)

plt.tight_layout()
plt.savefig("unicon-gp-periodicities.png", dpi=150, bbox_inches="tight", facecolor="#1b2838")
Figure 8: Learned periodic patterns from the GP — weekly occupancy cycle (left) and annual seasonality (right)

Weather Response Analysis

The GP’s weather kernel learns a nonlinear temperature–consumption relationship without imposing a parametric form. By sweeping temperature while holding other features at their mean, we extract the learned response curve.

Code
# ─── Synthetic temperature sweep ───
n_sweep = 200
temp_range_raw = np.linspace(0, 40, n_sweep)
feat_scaler = primary_ds["feat_scaler"]

# Create synthetic feature matrix: sweep temperature, hold others at mean (=0 in standardised space)
synthetic_features = np.zeros((n_sweep, len(all_features)))
# Set temp_mean (index 0) to swept values
temp_std = feat_scaler.scale_[0]
temp_mean_val = feat_scaler.mean_[0]
synthetic_features[:, 0] = (temp_range_raw - temp_mean_val) / temp_std

synthetic_x = torch.tensor(synthetic_features, dtype=torch.float32)

# Get weather component contribution only
primary_mdl.eval()
primary_lik.eval()
with torch.no_grad():
    output = primary_mdl(primary_ds["train_x"])
    K_noisy = primary_lik(output).covariance_matrix
    residual = (primary_ds["train_y"] - output.mean).unsqueeze(-1)
    alpha = torch.linalg.solve(K_noisy, residual).squeeze(-1)

    K_weather_cross = primary_mdl.k_weather(synthetic_x, primary_ds["train_x"]).evaluate()
    weather_response = (K_weather_cross @ alpha).numpy() * tgt_scale

    # Full prediction for uncertainty
    full_pred = gp_models[primary_cat]["likelihood"](primary_mdl(synthetic_x))
    full_mean = full_pred.mean.numpy() * tgt_scale + tgt_mean_val
    full_std = np.sqrt(full_pred.variance.numpy()) * tgt_scale

fig, ax = plt.subplots(figsize=(10, 6))

ax.plot(temp_range_raw, weather_response, color=COLORS["accent"], linewidth=2.5,
        label="Weather kernel component")
ax.fill_between(temp_range_raw,
                weather_response - 50, weather_response + 50,
                alpha=0.15, color=COLORS["accent"])
ax.axhline(0, color="#4a5568", linestyle=":", alpha=0.5)

# Mark typical comfort zone
ax.axvspan(15, 22, alpha=0.08, color="#68d391", label="Thermoneutral zone (~15–22°C)")

ax.set_xlabel("Mean Daily Air Temperature (°C)", fontweight="bold")
ax.set_ylabel("Weather Effect on Consumption (kWh)", fontweight="bold")
ax.set_title("Learned Temperature Response — Teaching Building", fontweight="bold", pad=15)
ax.legend(fontsize=9)
plt.tight_layout()
plt.savefig("unicon-gp-temp-response.png", dpi=150, bbox_inches="tight", facecolor="#1b2838")
Figure 9: Learned temperature response curve from the GP weather kernel — Bundoora Teaching building. The shape reflects combined heating and cooling demand.

Intervention Impact Analysis (ECM + COVID-19)

The GP trained on pre-COVID data provides a counterfactual: what consumption would have been without interventions. By comparing this to actual observations, we quantify the causal impact of COVID-19 and ECM events.

Code
# ─── Standardised residuals on test set ───
test_residuals_std = (
    (gp_predictions[primary_cat]["y_test"] - gp_predictions[primary_cat]["test_mean"])
    / gp_predictions[primary_cat]["test_std"]
)

fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 8), sharex=True)

test_dates = gp_predictions[primary_cat]["test_dates"]

# Top: standardised residuals
colors_res = np.where(np.abs(test_residuals_std) > 2.5, "#fc8181", COLORS["electricity"])
ax1.scatter(test_dates, test_residuals_std, c=colors_res, s=8, alpha=0.6, zorder=2)
ax1.axhline(0, color="#4a5568", linestyle=":", alpha=0.5)
ax1.axhline(2.5, color="#fc8181", linestyle="--", alpha=0.5, linewidth=1)
ax1.axhline(-2.5, color="#fc8181", linestyle="--", alpha=0.5, linewidth=1)
ax1.axvline(pd.Timestamp("2020-03-20"), color="#fc8181", linewidth=2, alpha=0.8)
ax1.text(pd.Timestamp("2020-04-05"), 4, "COVID-19\nShutdown", color="#fc8181",
         fontsize=9, fontweight="bold", va="top")
ax1.set_ylabel("Standardised Residual (σ)", fontweight="bold")
ax1.set_title(f"GP Residual Analysis — {primary_cat} Building", fontweight="bold", pad=15)

# Bottom: CUSUM
cusum = np.cumsum(test_residuals_std)
ax2.plot(test_dates, cusum, color=COLORS["electricity"], linewidth=1.5)
ax2.fill_between(test_dates, 0, cusum, alpha=0.15, color=COLORS["electricity"])
ax2.axhline(0, color="#4a5568", linestyle=":", alpha=0.5)
ax2.axvline(pd.Timestamp("2020-03-20"), color="#fc8181", linewidth=2, alpha=0.8)
ax2.set_xlabel("Date", fontweight="bold")
ax2.set_ylabel("Cumulative Sum of Residuals", fontweight="bold")
ax2.set_title("CUSUM Changepoint Detection", fontweight="bold", pad=10)

plt.tight_layout()
plt.savefig("unicon-gp-covid-cusum.png", dpi=150, bbox_inches="tight", facecolor="#1b2838")
Figure 10: COVID-19 structural break detection — standardised GP residuals (top) and CUSUM changepoint statistic (bottom)
Code
# ─── Counterfactual: set event indicators to 0 ───
counterfactual_x = primary_ds["test_x"].clone()
counterfactual_x[:, 12:15] = 0.0  # Zero out event indicators

primary_mdl_eval = gp_models[primary_cat]["model"]
primary_lik = gp_models[primary_cat]["likelihood"]

with torch.no_grad(), gpytorch.settings.fast_pred_var():
    cf_pred = primary_lik(primary_mdl_eval(counterfactual_x))
    cf_mean = cf_pred.mean.numpy()

cf_mean_orig = primary_ds["tgt_scaler"].inverse_transform(cf_mean.reshape(-1, 1)).ravel()
actual_mean_orig = gp_predictions[primary_cat]["test_mean"]

# Compute total savings
daily_savings = cf_mean_orig - actual_mean_orig
total_savings = np.sum(daily_savings)

fig, ax = plt.subplots(figsize=(14, 6))

ax.plot(test_dates, actual_mean_orig, color=COLORS["electricity"], linewidth=1.5,
        label="GP Prediction (with interventions)")
ax.plot(test_dates, cf_mean_orig, color="#fc8181", linewidth=1.5, linestyle="--",
        label="Counterfactual (no interventions)")
ax.fill_between(test_dates, actual_mean_orig, cf_mean_orig,
                where=(cf_mean_orig > actual_mean_orig),
                alpha=0.2, color="#fc8181", label="Estimated savings")

ax.axvline(pd.Timestamp("2020-03-20"), color="#fc8181", linestyle=":", alpha=0.5)

ax.set_xlabel("Date", fontweight="bold")
ax.set_ylabel("Daily Consumption (kWh)", fontweight="bold")
ax.set_title(f"Intervention Impact — {primary_cat} Building | Total savings: {total_savings:,.0f} kWh",
             fontweight="bold", pad=15)
ax.legend(fontsize=9, loc="upper right")
plt.tight_layout()
plt.savefig("unicon-gp-ecm-impact.png", dpi=150, bbox_inches="tight", facecolor="#1b2838")
Figure 11: Counterfactual analysis — GP prediction with vs without interventions. The shaded area represents estimated energy savings from ECM events and COVID-19.

Anomaly Detection

The GP’s calibrated predictive variance enables principled anomaly detection. Days where \(|z| > 2.5\sigma\) are flagged — contextually, meaning the model accounts for expected time-of-week, season, and weather before declaring an anomaly.

Code
ANOMALY_THRESHOLD = 2.5
anomaly_mask = np.abs(test_residuals_std) > ANOMALY_THRESHOLD
normal_mask = ~anomaly_mask
n_anomalies = anomaly_mask.sum()
anomaly_pct = n_anomalies / len(test_residuals_std) * 100

fig, ax = plt.subplots(figsize=(14, 6))

ax.scatter(test_dates[normal_mask],
           gp_predictions[primary_cat]["y_test"][normal_mask],
           color=COLORS["electricity"], s=5, alpha=0.3, label="Normal", zorder=1)
ax.scatter(test_dates[anomaly_mask],
           gp_predictions[primary_cat]["y_test"][anomaly_mask],
           color="#fc8181", s=25, edgecolors="white", linewidth=0.5,
           zorder=5, label=f"Anomaly (>{ANOMALY_THRESHOLD}σ, n={n_anomalies})")
ax.plot(test_dates, gp_predictions[primary_cat]["test_mean"],
        color=COLORS["accent"], linewidth=1, alpha=0.7, label="GP Mean")
ax.fill_between(test_dates,
                gp_predictions[primary_cat]["test_mean"] - ANOMALY_THRESHOLD * gp_predictions[primary_cat]["test_std"],
                gp_predictions[primary_cat]["test_mean"] + ANOMALY_THRESHOLD * gp_predictions[primary_cat]["test_std"],
                alpha=0.1, color=COLORS["accent"], label=f"±{ANOMALY_THRESHOLD}σ band")

ax.axvline(pd.Timestamp("2020-03-20"), color="#fc8181", linestyle="--", alpha=0.7)

ax.set_xlabel("Date", fontweight="bold")
ax.set_ylabel("Daily Consumption (kWh)", fontweight="bold")
ax.set_title(f"Anomaly Detection — {primary_cat} Building | {n_anomalies} anomalies ({anomaly_pct:.1f}%)",
             fontweight="bold", pad=15)
ax.legend(fontsize=9, loc="upper right")
plt.tight_layout()
plt.savefig("unicon-gp-anomalies.png", dpi=150, bbox_inches="tight", facecolor="#1b2838")
Figure 12: GP-based anomaly detection — days exceeding 2.5σ from the GP prediction are flagged as anomalous (red). Most anomalies cluster around the COVID-19 transition period.

Cross-Building Category Comparison

The same GP architecture is fitted to all 8 building categories. By comparing the posterior predictions and learned hyperparameters, we test whether the GP captures the fundamentally different physics of each building type — weekly occupancy patterns, seasonal sensitivity, and weather response.

Code
n_categories = len(gp_predictions)
fig, axes = plt.subplots(n_categories, 1, figsize=(14, 3.5 * n_categories), sharex=True)

for ax, cat in zip(axes, sorted(gp_predictions.keys())):
    pred = gp_predictions[cat]
    bid = gp_datasets[cat]["building_id"]
    color = cat_colors.get(cat, "#cbd5e1")

    # Train
    ax.scatter(pred["train_dates"], pred["y_train"],
               color=COLORS["muted"], s=2, alpha=0.2, zorder=1)
    ax.plot(pred["train_dates"], pred["train_mean"],
            color=color, linewidth=1.2, zorder=3)
    ax.fill_between(pred["train_dates"],
                    pred["train_mean"] - 1.96 * pred["train_std"],
                    pred["train_mean"] + 1.96 * pred["train_std"],
                    alpha=0.15, color=color, zorder=2)

    # Test
    ax.scatter(pred["test_dates"], pred["y_test"],
               color=COLORS["accent"], s=2, alpha=0.2, zorder=1)
    ax.plot(pred["test_dates"], pred["test_mean"],
            color=color, linewidth=1.2, zorder=3)
    ax.fill_between(pred["test_dates"],
                    pred["test_mean"] - 1.96 * pred["test_std"],
                    pred["test_mean"] + 1.96 * pred["test_std"],
                    alpha=0.1, color=color, zorder=2)

    ax.axvline(pd.Timestamp("2020-01-01"), color="#cbd5e1", linestyle=":", alpha=0.3)
    ax.axvline(pd.Timestamp("2020-03-20"), color="#fc8181", linestyle="--", alpha=0.4)
    ax.set_ylabel(f"{cat}\n(B{int(bid)})", fontweight="bold", fontsize=9)
    ax.tick_params(labelsize=8)

axes[0].set_title("GP Posterior — All Building Categories", fontweight="bold", pad=15, fontsize=13)
axes[-1].set_xlabel("Date", fontweight="bold")
plt.tight_layout()
plt.savefig("unicon-gp-cross-building.png", dpi=150, bbox_inches="tight", facecolor="#1b2838")
Figure 13: GP posterior predictions across all building categories — each panel shows observed consumption (dots), GP mean (line), and 95% CI (shaded)
Code
# ─── Extract key hyperparameters from all 7 models ───
hyperparam_data = []
for cat in sorted(gp_models.keys()):
    mdl = gp_models[cat]["model"]
    tgt_scale = gp_datasets[cat]["tgt_scaler"].scale_[0]

    hyperparam_data.append({
        "Category": cat,
        "Weekly Amplitude": mdl.k_weekly.outputscale.item() * tgt_scale,
        "Annual Amplitude": mdl.k_annual.outputscale.item() * tgt_scale,
        "Weather Scale": mdl.k_weather.outputscale.item() * tgt_scale,
        "Trend Scale": mdl.k_trend.outputscale.item() * tgt_scale,
        "Calendar Scale": mdl.k_calendar.outputscale.item() * tgt_scale,
        "Event Scale": mdl.k_event.outputscale.item() * tgt_scale,
        "Noise (σ²)": gp_models[cat]["likelihood"].noise.item() * tgt_scale**2,
    })

hp_df = pd.DataFrame(hyperparam_data).sort_values("Category")

# Plot 3 key hyperparameters
fig, (ax1, ax2, ax3) = plt.subplots(1, 3, figsize=(16, 6))

categories = hp_df["Category"].values
colors_list = [cat_colors.get(c, "#cbd5e1") for c in categories]
x_pos = range(len(categories))

# Weekly amplitude
ax1.bar(x_pos, hp_df["Weekly Amplitude"].values, color=colors_list,
        edgecolor="#2d3748", alpha=0.85)
ax1.set_xticks(x_pos)
ax1.set_xticklabels(categories, rotation=45, ha="right", fontsize=8)
ax1.set_ylabel("Output Scale (kWh)", fontweight="bold")
ax1.set_title("Weekly Kernel Amplitude\n(Weekday/Weekend Contrast)", fontweight="bold", fontsize=11)

# Annual amplitude
ax2.bar(x_pos, hp_df["Annual Amplitude"].values, color=colors_list,
        edgecolor="#2d3748", alpha=0.85)
ax2.set_xticks(x_pos)
ax2.set_xticklabels(categories, rotation=45, ha="right", fontsize=8)
ax2.set_ylabel("Output Scale (kWh)", fontweight="bold")
ax2.set_title("Annual Kernel Amplitude\n(Seasonal Variation)", fontweight="bold", fontsize=11)

# Weather scale
ax3.bar(x_pos, hp_df["Weather Scale"].values, color=colors_list,
        edgecolor="#2d3748", alpha=0.85)
ax3.set_xticks(x_pos)
ax3.set_xticklabels(categories, rotation=45, ha="right", fontsize=8)
ax3.set_ylabel("Output Scale (kWh)", fontweight="bold")
ax3.set_title("Weather Kernel Scale\n(Temperature Sensitivity)", fontweight="bold", fontsize=11)

plt.suptitle("Learned Hyperparameters Across Building Categories",
             fontweight="bold", fontsize=13, y=1.02)
plt.tight_layout()
plt.savefig("unicon-gp-hyperparams.png", dpi=150, bbox_inches="tight", facecolor="#1b2838")
Figure 14: Learned kernel hyperparameters across building categories — weekly amplitude (left), annual amplitude (centre), and weather lengthscale (right) reveal distinct building physics
Code
hp_display = hp_df.copy()
for col in hp_display.columns:
    if col != "Category":
        hp_display[col] = hp_display[col].apply(lambda x: f"{x:.1f}")
hp_display
Table 13: Full hyperparameter table — learned kernel scales across all building categories (units: kWh for amplitudes, kWh² for noise variance)
Category Weekly Amplitude Annual Amplitude Weather Scale Trend Scale Calendar Scale Event Scale Noise (σ²)
0 leased 0.9 0.8 49.0 72.7 134.2 74.5 1865.7
1 library 0.3 28.4 52.8 252.2 341.2 228.1 273414.5
2 mixed use 2.3 4.2 69.5 1030.5 168.4 163.8 9005.8
3 office 0.3 17.6 50.8 716.8 129.8 649.9 347365.1
4 other 0.8 15.4 3.4 225.1 14.4 212.2 9732.1
5 residence 1.2 3.0 27.8 225.1 13.2 327.8 42647.0
6 sport 34.6 4.5 8.7 67.9 176.1 140.1 9748.0
7 teaching 0.2 5.2 7.5 112.0 149.6 10.2 8908.9

Model Evaluation and Discussion

Code
from scipy import stats

# ─── Compute metrics for all buildings ───
metrics_rows = []
for cat in sorted(gp_predictions.keys()):
    pred = gp_predictions[cat]
    y_true = pred["y_test"]
    y_pred = pred["test_mean"]
    y_std = pred["test_std"]

    residuals = y_true - y_pred
    rmse = np.sqrt(np.mean(residuals**2))
    mae = np.mean(np.abs(residuals))
    mape = np.mean(np.abs(residuals) / np.maximum(np.abs(y_true), 1)) * 100

    # Coverage at 90% CI
    z_scores = np.abs(residuals) / y_std
    coverage_90 = np.mean(z_scores < 1.645) * 100
    coverage_95 = np.mean(z_scores < 1.96) * 100

    metrics_rows.append({
        "Category": cat,
        "Building": int(gp_datasets[cat]["building_id"]),
        "RMSE (kWh)": f"{rmse:.1f}",
        "MAE (kWh)": f"{mae:.1f}",
        "MAPE (%)": f"{mape:.1f}",
        "90% Coverage": f"{coverage_90:.1f}%",
        "95% Coverage": f"{coverage_95:.1f}%",
    })

# ─── 3-panel evaluation plot for primary building ───
pred = gp_predictions[primary_cat]
y_true = pred["y_test"]
y_pred = pred["test_mean"]
y_std = pred["test_std"]
residuals = y_true - y_pred
z_scores = residuals / y_std

fig, (ax1, ax2, ax3) = plt.subplots(1, 3, figsize=(16, 5))

# Panel 1: Actual vs Predicted
ax1.scatter(y_true, y_pred, s=5, alpha=0.4, color=COLORS["electricity"])
lims = [min(y_true.min(), y_pred.min()), max(y_true.max(), y_pred.max())]
ax1.plot(lims, lims, color="#fc8181", linestyle="--", linewidth=1.5, label="Perfect prediction")
ax1.set_xlabel("Observed (kWh)", fontweight="bold")
ax1.set_ylabel("Predicted (kWh)", fontweight="bold")
ax1.set_title("Predicted vs Observed", fontweight="bold")
ax1.legend(fontsize=8)

# Panel 2: Residual distribution
ax2.hist(z_scores, bins=50, color=COLORS["electricity"], edgecolor="#2d3748", alpha=0.85, density=True)
x_norm = np.linspace(-4, 4, 100)
ax2.plot(x_norm, stats.norm.pdf(x_norm), color="#fc8181", linewidth=2, linestyle="--",
         label="N(0,1) reference")
ax2.set_xlabel("Standardised Residual (σ)", fontweight="bold")
ax2.set_ylabel("Density", fontweight="bold")
ax2.set_title("Residual Distribution", fontweight="bold")
ax2.legend(fontsize=8)

# Panel 3: Calibration plot
ci_levels = np.arange(0.1, 1.0, 0.05)
expected_coverage = ci_levels
observed_coverage = []
for ci in ci_levels:
    z_threshold = stats.norm.ppf(0.5 + ci / 2)
    obs_cov = np.mean(np.abs(z_scores) < z_threshold)
    observed_coverage.append(obs_cov)

ax3.plot([0, 1], [0, 1], color="#fc8181", linestyle="--", linewidth=1.5, label="Perfect calibration")
ax3.plot(expected_coverage, observed_coverage, color=COLORS["electricity"],
         linewidth=2, marker="o", markersize=4, label="GP Model")
ax3.set_xlabel("Expected Coverage", fontweight="bold")
ax3.set_ylabel("Observed Coverage", fontweight="bold")
ax3.set_title("Calibration Reliability Diagram", fontweight="bold")
ax3.legend(fontsize=8)

plt.suptitle(f"Model Evaluation — {primary_cat} Building (Test Set: 2020–2022)",
             fontweight="bold", fontsize=13, y=1.02)
plt.tight_layout()
plt.savefig("unicon-gp-evaluation.png", dpi=150, bbox_inches="tight", facecolor="#1b2838")
Figure 15: Model evaluation for Teaching building — predicted vs observed (left), residual distribution (centre), and calibration reliability diagram (right)
Code
pd.DataFrame(metrics_rows).sort_values("Category")
Table 14: Forecast performance metrics across all building categories — RMSE, MAE, MAPE, and calibration coverage on the 2020–2022 test set
Category Building RMSE (kWh) MAE (kWh) MAPE (%) 90% Coverage 95% Coverage
0 leased 19 103.6 75.0 38.5 83.0% 86.1%
1 library 37 4161.4 3782.7 108.3 13.0% 14.5%
2 mixed use 4 468.3 402.7 160.8 23.0% 24.9%
3 office 23 1428.3 1188.0 56.2 62.6% 70.5%
4 other 15 294.1 248.0 23.3 80.5% 88.4%
5 residence 20 497.9 419.3 21.7 68.8% 84.7%
6 sport 35 485.5 420.2 48.8 41.1% 53.0%
7 teaching 62 490.6 445.0 57.9 12.1% 13.1%

Discussion: Hypothesis Testing Results

The multi-category GP comparison provides evidence for the following hypotheses:

Hypothesis Evidence Supported?
H1: Teaching buildings have stronger weekly periodicity Compare weekly kernel amplitudes across categories See hyperparameter table
H2: Temperature response is U-shaped Temperature response curve (Fig 4) shows shape See weather analysis
H3: COVID-19 impact varies by building type CUSUM analysis + cross-building predictions Compare residuals
H4: ECMs produce step-changes Counterfactual analysis (Fig 6) See event component
H5: Seasonal amplitude varies by category Compare annual kernel amplitudes See hyperparameter bars
H6: Weather sensitivity differs by category Compare weather kernel scales See hyperparameter bars
H8: Academic calendar modulates consumption Calendar kernel contributions See decomposition

Limitations and Future Work

Current limitations:

  • Daily aggregation loses intra-day patterns (the diurnal profile, peak demand timing)
  • Single-building models — each building is modelled independently; no information sharing
  • Stationary kernels — the locally periodic structure (PER × SE) would better capture evolving patterns but adds complexity
  • Two years of training data — limited exposure to extreme weather events

Proposed extensions for research:

  1. Hierarchical multi-output GP — share kernel hyperparameters across buildings via the Linear Model of Coregionalisation (LMC), enabling transfer learning to buildings with sparse data
  2. Deep Kernel Learning (Wilson et al., 2016) — replace handcrafted features with a neural network feature extractor, learning representations end-to-end
  3. Sub-daily resolution with SVGP — use Sparse Variational GP (Hensman et al., 2013) with ~2000 inducing points to model 15-minute data directly
  4. Normalizing flows for multi-modal consumption distributions
  5. Bayesian changepoint detection with sigmoid kernels for automatic regime identification

References

  • Rasmussen, C. E. and Williams, C. K. I. (2006). Gaussian Processes for Machine Learning. MIT Press.
  • Duvenaud, D. et al. (2013). “Structure Discovery in Nonparametric Regression through Compositional Kernel Search.” ICML.
  • Lloyd, J. R. et al. (2014). “Automatic Construction and Natural-Language Description of Nonparametric Regression Models.” AAAI.
  • Hensman, J. et al. (2013). “Gaussian Processes for Big Data.” UAI.
  • Wilson, A. G. et al. (2016). “Deep Kernel Learning.” AISTATS.
  • Moraliyage, H. et al. (2022). “UNICON: An Open Dataset of Electricity, Gas and Water Consumption.” IEEE HSI.

Model implementation: GPyTorch with PyTorch backend. All code available with code-fold — click “Code” to expand.