Turku · available now

Valtteri Murtomäki

Data Engineer — Pipelines and Risk Models

I build data pipelines and the statistical models that run on them: what a price change really costs in sales, how wrong a demand forecast could be, how bad a bad day on electricity prices gets. Each one ships as a version-controlled system — not a notebook.

Statistician (M.Sc., Turku 2026). Previously ran regulated cold-chain logistics — where the habit of building systems that fail loudly came from.

Statistics & Modelling

Time Series & Volatility Modelling (GARCH) Extreme Value Theory Causal Inference (Double ML) Conformal Prediction Model Validation & Backtesting Bayesian Inference

Languages & Tools

Python SQL / T-SQL R Pandas / NumPy / SciPy Scikit-learn LightGBM Streamlit / Plotly

Cloud & Engineering

Azure SQL Terraform Docker GitHub Actions (OIDC) Pytest MyPy (strict) / Ruff uv
Case studies

Selected Work

Five projects spanning tail-risk forecasting, causal inference, prediction intervals, regulatory reporting measurement, and cross-register data quality.

P01

GARCH-EVT Risk Engine — Azure Deployment (Decommissioned 2026-10-15)

Azure SQL ServerlessTerraform GitHub Actions OIDCPandera Pytestarch

Ran unattended every day from 16 August to 4 September 2026 to answer one question for anyone exposed to Finnish electricity prices — how bad could a bad day get — and halted rather than publishing when the input series had a gap it could not defensibly fill or the volatility model failed to converge.

The first out-of-sample backtest rejected 99% coverage — 8 breaches against 2.44 expected, Kupiec p = 0.0047 — and the forecast was withheld. On the current pinned range: 14 breaches against 14.05 expected at 95% over 281 days (Kupiec p = 0.99), with the 99% level reported alongside its power caveat rather than as confirmation.

  • That fix changed four things at once (threshold selector, rolling window, exceedance floor, evaluation period). A later ablation cleared the selector and the floor; window length and evaluation period remain confounded, and cannot be separated because the 2023 range no longer exists upstream. The original single-cause attribution was wrong, and its replacement is narrower rather than conclusive. Both the failure and the correction are in the devlog.
  • Rebuilt a local research pipeline as a passwordless, zero-secret Azure deployment: Terraform-provisioned Azure SQL Serverless, Blob-backed remote state, GitHub Actions authenticated via OIDC federated credentials.
  • Built an idempotent ingestion layer (staging table + T-SQL MERGE) where source cadence is not assumed (resampling the 15-minute market time unit to hourly means before Pandera contracts enforce a gapless, timezone-aware index), and exponential backoff to survive Azure SQL Serverless cold starts (error 40613).
  • Fitted a rolling GJR-GARCH(1,1,1) + Peaks-Over-Threshold (POT/GPD) EVT engine with daily Kupiec POF and Christoffersen conditional-coverage tests, plus a raw Acerbi–Székely ES statistic that is reported rather than thresholded (it follows no standard distribution); the pipeline halted on unfillable data gaps or convergence failures rather than silently dropping days.
  • Benchmarked against filtered historical simulation, a GARCH-t quantile, and an unconditional empirical quantile over an identical 281-day scheme. No arm separates on coverage — including the unfiltered one — so the honest reading is that the sample lacks the power to rank them; GARCH-EVT differs only in running 10–15% more conservative at 99%.
  • Fixed three Azure/ODBC/SQLAlchemy production issues: OIDC subject-claim mismatch, driver auth keyword collision, SQL type inference on tz-aware timestamps.
P02

Causal Pricing Engine

PythonDoubleML LightGBMStreamlit PytestMyPy

Tells a pricing team how much of their brand's share a price change would actually move, separating real customer response from the brand loyalty and competitor discounting that make raw sales data misleading — and lets them test a scenario in a dashboard before committing.

A 10-cent increase in sale price lowers the probability of choosing the brand by ~4.6 percentage points (θ̂₀ = −0.460, p < 0.001), holding loyalty and competitor price and promotion fixed. Estimated on a linear probability scale, so this is a local slope, not a calibrated probability model.

  • Read as a local slope, not a dollar-scale prediction: the treatment (sale price) spans $1.39–$2.09, so a full $1.00 move is outside the support of the data.
  • Omitted-variable-bias sensitivity puts the robustness value at 12.3%: at worst-case alignment, an unobserved confounder would need to explain ~12.3% of residual variance in both treatment and outcome to null the point estimate — 8.1% to null significance. Not yet benchmarked against the observed covariates, so it bounds fragility without establishing that no such confounder exists.
  • Observations cluster by store and week but the model was fitted with i.i.d. standard errors, so the p-value is understated; the point estimate is the defensible quantity.
  • Estimated the effect with a partially linear DML model on the partialling-out score — LightGBM nuisance learners, 5 folds × 5 repetitions. PriceCH and SpecialCH are excluded together: sale price is list price minus discount, and the discount is near-zero absent a promotion, so conditioning on both leaves almost no residual treatment variation. The cost is that list-price level goes uncontrolled — a stated trade-off, not a free lunch.
  • Validated the estimator against a synthetic design with known θ₀ and a second with an analytically derived omitted-variable bias, both asserted in CI alongside Ruff, MyPy (strict), and a locked dependency graph.
  • Built a typed src/ layout with a Streamlit simulator that reads the pipeline artifact rather than refitting.
P03

Adaptive Conformal Forecasting

Distribution-free prediction intervals for volatile time series
PythonMAPIE DockerPlotly PytestMyPy

Turns a single-number electricity demand forecast into a 90% prediction interval — the band the outcome falls inside nine hours in ten, not a best case or a worst case — that planners can size capacity and hedging decisions against. The band recalibrates itself against realized coverage as the series shifts regime, instead of holding a width that stopped being correct.

On 1,500 held-out hours at a 90% target, the adaptive layer covered 89.9% with intervals 7.5% narrower than a static conformal baseline that over-covered at 92.7% (sweep evaluated on the reporting window, so the reduction is optimistically biased).

  • Reported with its resolution: at n = 1,500 the binomial SE on coverage is ≈0.8 pp before correcting for serially dependent misses, so 89.9% is indistinguishable from the 90% target — the separation from 92.7% is the informative comparison, not the last decimal.
  • The γ sweep behind that default was run on the same held-out window the numbers are reported on, so the width reduction is optimistically biased; a three-way split is the first structural fix. γ = 0.005 gave a larger reduction (10.9%) than the chosen 0.01, so the default is a judgment call on adaptation speed rather than an optimum the sweep establishes.
  • Above γ ≈ 0.03 the update overshoots after miscoverage events — coverage still tracks 90%, but mean width inflates sharply (47% wider than static at γ = 0.05).
  • Applied EnbPI with block-bootstrap residuals (MAPIE) in place of split conformal — a weaker assumption, not no assumption: coverage becomes approximate and asymptotic under stationarity and mixing conditions rather than finite-sample under exchangeability.
  • Fixed an ACI update that ran once per 168-hour chunk on the chunk's aggregate error rate instead of per timestep — roughly two orders of magnitude coarser than the Gibbs & Candès recursion. The α path is now per-step; the emitted band stays piecewise-constant over the batching interval, so the displayed interval is a downsampled view of it.
  • Containerized the stack with Docker and wrote one end-to-end validation script covering install, tests, lint, image build, and container health check.
P04

ESEF Machine-Readability Benchmark

PythonSQLite XBRL / iXBRLPytest MyPy (strict)Cluster bootstrap

EU-listed companies are required to file their annual accounts in a machine-readable format, so software should be able to find the headline figures — revenue, profit, assets, equity, liabilities, cash — without guessing. This measures how often it actually can, on every Finnish issuer that filed.

93.9% of 882 issuer-field cells carry the ESEF core standard element (95% cluster-bootstrap CI 91.8%–95.7%, 147 issuer clusters, B = 10,000). The gap is not spread evenly: liabilities is tagged in 79.6% of filings and revenue in 89.8%, while the other four sit above 98%.

  • The dominant cause is legitimate presentation, not defective tagging: 30 of 147 issuers report equity and liabilities split into non-current and current components, with no bare total line to tag. That is a valid IAS 1 statement and a machine-readability gap at the same time, which is the finding.
  • Excluded cells stay in the denominator. Conditioning on classifiability would have defined away part of the thing being measured; the exclusions are published by reason code so a reader can apply their own test.
  • The pre-registered reason for clustering on the issuer was falsified by the data and is published as falsified: shared templates and tagging vendors predict an intraclass correlation near 1, and the measured value is 0.1099. The clustering is still correct; the argument for it was not.
  • Stopped the planned LLM extraction arm on a measurement, before a single document was sent: the deepest lossless redaction still left a mean 1,331,978-token document against a 1,050,000-token context window, at $51.66 projected for this frame. Recorded VOID rather than FAIL — a gate whose inputs were never observed has no verdict — and the scope change is a dated amendment, not a rewritten spec.
  • Removed a verification script that ran the scripts writing its own inputs and then asserted PASS on them, and traced a one-character constant corruption that disabled the entire redactor while mypy --strict reported success. Three reasoned diagnoses were wrong; printing the bytes at the failing offset settled it.
P05

Procurement Awards Against the Trade Register — Databricks and dbt

DatabricksUnity Catalog / Delta dbtPython Power BI (PBIP/TMDL)Pytest

When a Finnish public procurement award names a supplier and its business id, does the national trade register hold a company under that id, and does its legal name agree with the name in the notice? Measured for every contractor occurrence in one contiguous frame of Hilma award notices — 32,831 notices and 12,434 contractor occurrences, published 2023-03-09 to 2026-09-07 — against the PRH trade register bulk, through a bronze/silver/gold dbt pipeline on Databricks Free Edition, a star schema, and a Power BI model with dynamic row-level security on top.

Of the 11,479 occurrences that carry a business id, 81.56% (9,362) name a supplier active at award whose register name agrees — exact, normalised or former — and 66.27% (7,607) match exactly. Both are lower bounds: a supplier missing from the PRH snapshot counts against them, and backfilling the 179 missing ids that resolve on the live PRH API gives measured ceilings of 84.98% and 66.30%.

Power BI report page: a table of the six headline measures (name exact 7607, name agrees 9362, exact coverage 66.3 %, denominator 11479, fact rows 12434, coverage 81.6 %) and a second table showing 19.

The Power BI report, refreshed from the dev warehouse on 2026-09-25: whole-frame totals only.

  • “Not in PRH” means absent from a snapshot, not unknown to the register: 1,206 fact rows over 585 business ids are missing from the 2026-09-07 bulk of 463,246 records. 179 of those ids resolve on the live PRH API; in an earlier census of 559 of them, 164 of the 169 that resolved were ceased companies.
  • Two Hilma endpoints, two id spaces, no error: fetching a search id of the form EF-<n> returned an unrelated migrated notice with HTTP 200, not 404, so an id list built from search would have loaded wrong notices silently. The frame is therefore an id backfill, not a query by procurement category.
  • Most name disagreement is real: of 884 rows whose register names all disagree with the notice, 825 rows on 322 ids match no PRH name of any type, and 59 rows on 21 ids match only an auxiliary trade name (55) or a parallel name (4), name types the model excludes.
  • Every architecture choice follows from a measured platform constraint: extraction runs on the workstation because serverless egress fails at DNS for Hilma, PRH and GLEIF; dbt runs as one job task because job concurrency is 1; a provisioning script recreates the catalog before any run because catalogs are not guaranteed durable; and no access token goes into CI because the SKU has no account-level API for OIDC federation.
  • 43 data tests and 2 unit tests; the last full dbt build on the dev warehouse passed 56 of 56 nodes. The lineage and star-schema diagrams in the README are generated from tracked files, and a tracked pre-commit hook rejects CR, NUL and secret patterns.
  • Hilma payloads carry personal data and supplier names can include sole traders, so the extractor keeps fields by a whitelist and the project publishes counts only: no supplier names and no business ids.
P06

Repository Standard and Release Control (repo-audit)

BashBubblewrap GitLab CIGit bundles Git hooksLinux / WSL

One repository standard for all five portfolio repositories: a shared byte-scan gate, a drift check against the hub's copies, and a release procedure in which each change is developed, reviewed and merged under separate Linux users.

The landing step runs no code from the task branch. It accepts a handed-off bundle only if the bundle's head matches the handed-off commit exactly, and the merge goes through a merge request into a protected main.

  • Each role runs as its own Linux user in its own WSL distro: coding agent (Claude Code in an rc.sh box bwrap sandbox with filtered proxy), independent review (Antigravity in an isolated clone), and credentialed merge host.
  • The land subcommand integrates task bundles without executing task-branch code (Git hooks disabled, no build scripts), verifies bundle ownership, and requires the bundle head to match the handed-off commit SHA exactly.
  • The D7 byte scan (scripts/check_bytes.sh) checks tracked text files in CI and pre-commit hooks, rejecting UTF-8 BOM markers, CR line endings, NUL bytes, and key-shaped secret patterns.
  • Drift detection (scripts/drift.sh) performs read-only checks comparing child clones against the hub's canonical D7 files (.gitattributes, .githooks/pre-commit, and scripts/check_bytes.sh), returning exit code 0 when synchronized and exit code 1 on any file drift.
  • Documented deviations from the standard: no SAST (the hub has no application code); CI only on non-draft merge requests into main; branch protection confirmed only by the settings UI (push test skipped); CI image tag debian:trixie-slim pinned by digest.