prod: load versioned model artifact at runtime
All checks were successful
ENGIN CI / Build, test and smoke (push) Successful in 2m58s

This commit is contained in:
Jakub Famulski 2 2026-08-25 13:06:14 +02:00
parent 8981ae0a00
commit 8e6dcb750d
20 changed files with 1119 additions and 396 deletions

View File

@ -10,3 +10,9 @@ robustness_quick
severity_outputs severity_outputs
severity_quick severity_quick
ml_polish_outputs ml_polish_outputs
archive
docs
presentation
train.csv
1_przebiegi_usterek.png
2_przebiegi_silnika.png

View File

@ -23,42 +23,65 @@ jobs:
run: | run: |
docker rm -f engin-ci-app 2>/dev/null || true docker rm -f engin-ci-app 2>/dev/null || true
docker image rm -f engin-console:ci 2>/dev/null || true docker image rm -f engin-console:ci 2>/dev/null || true
docker image rm -f engin-console:test 2>/dev/null || true
- name: Build production image - name: Build test image
run: docker build --tag engin-console:ci . run: docker build --target test --tag engin-console:test .
- name: Run Ruff - name: Run Ruff
run: | run: |
docker run --rm \ docker run --rm \
--entrypoint sh \ --entrypoint sh \
engin-console:ci \ engin-console:test \
-c "python -m pip install --quiet \ -c "python -m pip install --quiet \
--disable-pip-version-check \ --disable-pip-version-check \
--root-user-action=ignore \ --root-user-action=ignore \
ruff==0.16.4 && ruff==0.16.4 &&
ruff check --no-cache \ ruff check --no-cache \
app.py engin tests final_pipeline.py ml_polish_benchmark.py" app.py engin tests final_pipeline.py ml_polish_benchmark.py scripts"
- name: Check installed dependencies - name: Check installed dependencies
run: | run: |
docker run --rm \ docker run --rm \
--entrypoint python \ --entrypoint python \
engin-console:ci \ engin-console:test \
-m pip check -m pip check
- name: Run test suite - name: Run test suite
run: | run: |
docker run --rm \ docker run --rm \
--entrypoint python \ --entrypoint python \
engin-console:ci \ engin-console:test \
-m unittest discover -v -m unittest discover -v
- name: Verify frozen model artifact
run: |
docker run --rm \
--entrypoint python \
engin-console:test \
-m scripts.build_model_artifact --verify-only
- name: Validate Python syntax - name: Validate Python syntax
run: | run: |
docker run --rm \ docker run --rm \
--entrypoint python \ --entrypoint python \
engin-console:test \
-m compileall -q app.py engin tests final_pipeline.py scripts
- name: Build production image
run: docker build --target runtime --tag engin-console:ci .
- name: Assert runtime excludes training assets
run: |
docker run --rm \
--entrypoint sh \
engin-console:ci \ engin-console:ci \
-m compileall -q app.py engin tests final_pipeline.py -c "test ! -e val.csv &&
test ! -e train.csv &&
test ! -e final_pipeline.py &&
test ! -e severity_benchmark.py &&
test -s artifacts/engin-2026.08.25-1/model.pkl &&
test -s artifacts/engin-2026.08.25-1/manifest.json"
- name: Start application - name: Start application
run: | run: |
@ -90,3 +113,4 @@ jobs:
docker logs engin-ci-app 2>/dev/null || true docker logs engin-ci-app 2>/dev/null || true
docker rm -f engin-ci-app 2>/dev/null || true docker rm -f engin-ci-app 2>/dev/null || true
docker image rm -f engin-console:ci 2>/dev/null || true docker image rm -f engin-console:ci 2>/dev/null || true
docker image rm -f engin-console:test 2>/dev/null || true

View File

@ -1,4 +1,5 @@
FROM python:3.12-slim ARG PYTHON_IMAGE=python:3.12-slim@sha256:7a8b475003c4fe15a2cd4e55e5cfc2f3560bdc9333d624f24cdd6d4340fd7a17
FROM ${PYTHON_IMAGE} AS base
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
@ -9,8 +10,18 @@ WORKDIR /app
COPY requirements-lock.txt ./ COPY requirements-lock.txt ./
RUN pip install --no-cache-dir -r requirements-lock.txt RUN pip install --no-cache-dir -r requirements-lock.txt
FROM base AS test
COPY . . COPY . .
FROM base AS runtime
COPY app.py ./
COPY engin/ ./engin/
COPY assets/ ./assets/
COPY .streamlit/ ./.streamlit/
COPY artifacts/ ./artifacts/
COPY test.csv predictions.csv prediction_diagnostics.csv ./
EXPOSE 8501 EXPOSE 8501
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \

View File

@ -4,7 +4,8 @@ Gotowy do demonstracji system diagnostyki cylindrów przemysłowych silników Di
## Uruchomienie ## Uruchomienie
Wymagany jest Python 3.12 (3.11 również powinien działać). Wymagany jest Python 3.12. Artefakt modelu sprawdza zgodność wersji Pythona
i bibliotek przed deserializacją.
```bash ```bash
python -m venv .venv python -m venv .venv
@ -48,6 +49,8 @@ docker run --rm -p 8501:8501 engin-console
Interfejs jest cienką warstwą prezentacji. Zależności są wstrzykiwane do `DiagnosticService`, dlatego odczyt pliku, walidator i model można niezależnie wymieniać oraz testować. Interfejs jest cienką warstwą prezentacji. Zależności są wstrzykiwane do `DiagnosticService`, dlatego odczyt pliku, walidator i model można niezależnie wymieniać oraz testować.
Proces produkcyjny nie trenuje modelu przy starcie. Aplikacja weryfikuje checksumę, schemat wejścia i wersje bibliotek, a następnie ładuje artefakt `engin-2026.08.25-1`. Uszkodzony lub niezgodny artefakt uruchamia ograniczony fallback demonstracyjny.
```mermaid ```mermaid
flowchart TD flowchart TD
UI["Streamlit UI"] --> S["DiagnosticService"] UI["Streamlit UI"] --> S["DiagnosticService"]
@ -64,6 +67,9 @@ app.py cienka warstwa Streamlit
engin/io.py adapter odczytu CSV engin/io.py adapter odczytu CSV
engin/validation.py kontrakt i walidacja danych engin/validation.py kontrakt i walidacja danych
engin/model.py adapter modelu i fallback demo engin/model.py adapter modelu i fallback demo
engin/artifact.py manifest, checksumy i ładowanie artefaktu
engin/features.py stabilne transformacje używane w train/inference
engin/inference.py runtime predykcji bez zależności od benchmarków
engin/service.py przypadek użycia / dependency injection engin/service.py przypadek użycia / dependency injection
engin/explainability.py status, porządkowy triage i uzasadnienia engin/explainability.py status, porządkowy triage i uzasadnienia
engin/charts.py czyste, testowalne fabryki Plotly engin/charts.py czyste, testowalne fabryki Plotly
@ -102,6 +108,17 @@ python final_pipeline.py
Powstają `predictions.csv` (600/600 rekordów, bez duplikatów i braków) oraz `prediction_diagnostics.csv` używany przez aplikację do explainability. Powstają `predictions.csv` (600/600 rekordów, bez duplikatów i braków) oraz `prediction_diagnostics.csv` używany przez aplikację do explainability.
Wersjonowany artefakt modelu buduje osobny krok release:
```bash
python -m scripts.build_model_artifact \
--source-revision "$(git rev-parse HEAD)"
python -m scripts.build_model_artifact --verify-only
```
Druga komenda nie trenuje modelu. Ładuje artefakt, sprawdza manifest i checksumę oraz wymaga dokładnej zgodności wszystkich 600 predykcji z zamrożonym `predictions.csv`.
## Kontrakt danych wejściowych ## Kontrakt danych wejściowych
Każdy wiersz reprezentuje cylinder. Wymagane kolumny: Każdy wiersz reprezentuje cylinder. Wymagane kolumny:
@ -121,13 +138,14 @@ Pełna kontrola projektu:
python -m unittest discover -v python -m unittest discover -v
``` ```
Pakiet zawiera 32 testy: Pakiet zawiera 38 testów:
- testy braku leakage, cech, OOD i kontraktu submission, - testy braku leakage, cech, OOD i kontraktu submission,
- testy jednostkowe walidatora i błędnych CSV, - testy jednostkowe walidatora i błędnych CSV,
- testy serwisu z fake reader/validator/model — weryfikują dependency injection, - testy serwisu z fake reader/validator/model — weryfikują dependency injection,
- testy explainability, rankingu i wykresów, - testy explainability, rankingu i wykresów,
- testy smoke Streamlit dla demo, pustego uploadu i synchronizacji mapy cylindrów ze szczegółami. - testy smoke Streamlit dla demo, pustego uploadu i synchronizacji mapy cylindrów ze szczegółami.
- testy artefaktu: checksumy, zgodność bibliotek, brak odczytu danych treningowych przy starcie i regresja predykcji 600/600.
Szybka kontrola serwera: Szybka kontrola serwera:
@ -141,7 +159,8 @@ Oczekiwana odpowiedź: `ok`.
## Odporność operacyjna ## Odporność operacyjna
- Model jest trenowany raz i przechowywany w cache procesu. - Model jest trenowany poza aplikacją i ładowany raz z wersjonowanego artefaktu do cache procesu.
- Finalny obraz runtime nie zawiera `val.csv`, `train.csv` ani skryptów treningowych.
- Nieoczekiwany błąd inicjalizacji modelu uruchamia read-only fallback dla danych demo. - Nieoczekiwany błąd inicjalizacji modelu uruchamia read-only fallback dla danych demo.
- Nieprawidłowe dane nie docierają do modelu. - Nieprawidłowe dane nie docierają do modelu.
- Błędy użytkownika mają stabilne kody i nie pokazują tracebacków w interfejsie. - Błędy użytkownika mają stabilne kody i nie pokazują tracebacków w interfejsie.

21
app.py
View File

@ -34,6 +34,8 @@ from engin.validation import SpectrumFrameValidator
LOGGER = logging.getLogger("engin.app") LOGGER = logging.getLogger("engin.app")
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent
MODEL_VERSION = "engin-2026.08.25-1"
MODEL_ARTIFACT_DIR = BASE_DIR / "artifacts" / MODEL_VERSION
@dataclass(frozen=True) @dataclass(frozen=True)
@ -42,24 +44,28 @@ class AppDependencies:
demo_payload: bytes demo_payload: bytes
live_inference: bool = True live_inference: bool = True
startup_warning: str | None = None startup_warning: str | None = None
model_version: str = "unknown"
def _reader() -> PandasCsvReader: def _reader() -> PandasCsvReader:
return PandasCsvReader(AppConfig().max_upload_bytes) return PandasCsvReader(AppConfig().max_upload_bytes)
@st.cache_resource(show_spinner="Przygotowanie modelu diagnostycznego…") @st.cache_resource(show_spinner="Ładowanie modelu diagnostycznego…")
def build_dependencies() -> AppDependencies: def build_dependencies() -> AppDependencies:
reader = _reader() reader = _reader()
validator = SpectrumFrameValidator() validator = SpectrumFrameValidator()
demo_path = BASE_DIR / "test.csv" demo_path = BASE_DIR / "test.csv"
demo_payload = demo_path.read_bytes() demo_payload = demo_path.read_bytes()
try: try:
reference = reader.read_path(BASE_DIR / "val.csv") model = SklearnPredictionModel.from_artifact(
model = SklearnPredictionModel.train(reference, random_state=42, n_jobs=-1) MODEL_ARTIFACT_DIR,
expected_model_version=MODEL_VERSION,
)
return AppDependencies( return AppDependencies(
service=DiagnosticService(reader=reader, validator=validator, model=model), service=DiagnosticService(reader=reader, validator=validator, model=model),
demo_payload=demo_payload, demo_payload=demo_payload,
model_version=MODEL_VERSION,
) )
except Exception: except Exception:
LOGGER.exception("Live model initialization failed; enabling demo fallback") LOGGER.exception("Live model initialization failed; enabling demo fallback")
@ -71,9 +77,10 @@ def build_dependencies() -> AppDependencies:
demo_payload=demo_payload, demo_payload=demo_payload,
live_inference=False, live_inference=False,
startup_warning=( startup_warning=(
"Model live nie został przygotowany. Aplikacja działa w bezpiecznym " "Artefakt modelu nie został załadowany. Aplikacja działa w bezpiecznym "
"trybie demonstracyjnym na zapisanych predykcjach." "trybie demonstracyjnym na zapisanych predykcjach."
), ),
model_version="demo-precomputed",
) )
@ -236,7 +243,7 @@ def _render_technical(result: DiagnosisResult) -> None:
c1.metric("Grouped Macro F1", "0.981") c1.metric("Grouped Macro F1", "0.981")
c2.metric("Severity accuracy", "0.930") c2.metric("Severity accuracy", "0.930")
c3.metric("Walidacyjne ML", "33.65 / 40") c3.metric("Walidacyjne ML", "33.65 / 40")
c4.metric("Testy", "32/32") c4.metric("Bramka jakości", "CI GREEN")
st.markdown( st.markdown(
""" """
- **Zero leakage:** każdy fold zawiera kompletne, wcześniej niewidziane silniki. - **Zero leakage:** każdy fold zawiera kompletne, wcześniej niewidziane silniki.
@ -316,7 +323,9 @@ def render_app(dependencies: AppDependencies | None = None) -> None:
width="stretch", width="stretch",
) )
st.divider() st.divider()
st.caption("Model ENGIN v1 · CPU inference · brak połączeń zewnętrznych") st.caption(
f"Model {deps.model_version} · CPU inference · brak połączeń zewnętrznych"
)
analysis = analyze_engine(result, str(engine_id)) analysis = analyze_engine(result, str(engine_id))
summary = summarize_engine(analysis) summary = summarize_engine(analysis)

View File

@ -0,0 +1,49 @@
{
"artifact_format_version": 1,
"created_at_utc": "2026-08-25T10:57:21.704599+00:00",
"input_columns": [
"engine_id",
"cylinder",
"n_cylinders",
"mV_0",
"mV_1",
"mV_2",
"mV_3",
"mV_4",
"mV_5",
"mV_6",
"mV_7",
"mV_8",
"mV_9",
"mV_10",
"mV_11",
"mV_12",
"mV_13",
"mV_14",
"mV_15",
"mV_16",
"mV_17",
"mV_18",
"mV_19",
"mV_20"
],
"label_model": "deviation_logistic_c10",
"model_sha256": "a649e96ab13dd49a69d73e12fab46f830d128dac49a93464998a4bdfe8cbec2f",
"model_version": "engin-2026.08.25-1",
"n_jobs": 1,
"ood_absolute_threshold_mv": 7.25,
"ood_ratio_threshold": 2.5,
"python_version": "3.12.13",
"random_state": 42,
"runtime_versions": {
"joblib": "1.5.3",
"numpy": "2.3.5",
"pandas": "2.2.3",
"scikit-learn": "1.8.0",
"scipy": "1.18.1",
"threadpoolctl": "3.6.0"
},
"severity_model": "deviation_extra_trees_mf03",
"source_revision": "8981ae0a0086e1bd6d8ab6900ddd5a78bc92a304",
"training_data_sha256": "82120af46fa62eefc1cc62f7cd953c4af830207f1d5083dccff5900fb57737f1"
}

Binary file not shown.

View File

@ -33,13 +33,9 @@ from sklearn.model_selection import GroupKFold, StratifiedGroupKFold
from sklearn.pipeline import Pipeline from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler from sklearn.preprocessing import StandardScaler
from engin.config import FAULT_LABELS, FREQ_COLS, LABELS, NOT_APPLICABLE, SEVERITIES
RANDOM_STATE = 42 RANDOM_STATE = 42
FREQ_COLS = [f"mV_{frequency}" for frequency in range(21)]
LABELS = ["ok", "zakoksowany", "lejacy", "pompa", "iglica", "unknown"]
FAULT_LABELS = ["zakoksowany", "lejacy", "pompa", "iglica"]
SEVERITIES = ["male", "srednie", "duze"]
NOT_APPLICABLE = "nie_dotyczy"
FEATURE_SETS = ("raw", "relative", "combined") FEATURE_SETS = ("raw", "relative", "combined")
MODEL_NAMES = ("logistic", "extra_trees") MODEL_NAMES = ("logistic", "extra_trees")

257
engin/artifact.py Normal file
View File

@ -0,0 +1,257 @@
"""Versioned, checksummed model-artifact persistence for runtime loading."""
from __future__ import annotations
import hashlib
import json
import os
import pickle
import platform
import tempfile
from dataclasses import asdict, dataclass
from datetime import UTC, datetime
from importlib.metadata import version
from pathlib import Path
from .inference import (
INFERENCE_COLUMNS,
OOD_OK_TO_UNKNOWN_RATIO_THRESHOLD,
OOD_OK_TO_UNKNOWN_THRESHOLD_MV,
DiagnosticModels,
)
ARTIFACT_FORMAT_VERSION = 1
MODEL_FILENAME = "model.pkl"
MANIFEST_FILENAME = "manifest.json"
RUNTIME_PACKAGES = (
"joblib",
"numpy",
"pandas",
"scikit-learn",
"scipy",
"threadpoolctl",
)
class ModelArtifactError(RuntimeError):
"""Raised when a model artifact is missing, corrupt, or incompatible."""
@dataclass(frozen=True)
class ArtifactMetadata:
artifact_format_version: int
model_version: str
created_at_utc: str
source_revision: str
training_data_sha256: str
model_sha256: str
input_columns: tuple[str, ...]
python_version: str
random_state: int
n_jobs: int
label_model: str
severity_model: str
ood_absolute_threshold_mv: float
ood_ratio_threshold: float
runtime_versions: dict[str, str]
@classmethod
def from_dict(cls, payload: dict[str, object]) -> "ArtifactMetadata":
try:
return cls(
artifact_format_version=int(payload["artifact_format_version"]),
model_version=str(payload["model_version"]),
created_at_utc=str(payload["created_at_utc"]),
source_revision=str(payload["source_revision"]),
training_data_sha256=str(payload["training_data_sha256"]),
model_sha256=str(payload["model_sha256"]),
input_columns=tuple(str(value) for value in payload["input_columns"]),
python_version=str(payload["python_version"]),
random_state=int(payload["random_state"]),
n_jobs=int(payload["n_jobs"]),
label_model=str(payload["label_model"]),
severity_model=str(payload["severity_model"]),
ood_absolute_threshold_mv=float(
payload["ood_absolute_threshold_mv"]
),
ood_ratio_threshold=float(payload["ood_ratio_threshold"]),
runtime_versions={
str(name): str(package_version)
for name, package_version in dict(
payload["runtime_versions"]
).items()
},
)
except (KeyError, TypeError, ValueError) as exc:
raise ModelArtifactError("Model manifest has an invalid schema.") from exc
def to_dict(self) -> dict[str, object]:
payload = asdict(self)
payload["input_columns"] = list(self.input_columns)
return payload
@dataclass(frozen=True)
class LoadedModelArtifact:
models: DiagnosticModels
metadata: ArtifactMetadata
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _runtime_versions() -> dict[str, str]:
return {package: version(package) for package in RUNTIME_PACKAGES}
def _atomic_write(path: Path, payload: bytes) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary_name = tempfile.mkstemp(
dir=path.parent, prefix=f".{path.name}.", suffix=".tmp"
)
temporary_path = Path(temporary_name)
try:
with os.fdopen(descriptor, "wb") as handle:
handle.write(payload)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary_path, path)
except Exception:
temporary_path.unlink(missing_ok=True)
raise
def save_model_artifact(
models: DiagnosticModels,
directory: Path,
*,
model_version: str,
source_revision: str,
training_data_sha256: str,
random_state: int,
n_jobs: int,
label_model: str,
severity_model: str,
) -> ArtifactMetadata:
if not model_version.strip():
raise ValueError("model_version cannot be empty.")
if len(training_data_sha256) != 64 or any(
character not in "0123456789abcdef" for character in training_data_sha256
):
raise ValueError("training_data_sha256 must be a SHA-256 hex digest.")
envelope = {
"artifact_format_version": ARTIFACT_FORMAT_VERSION,
"model_version": model_version,
"models": models,
}
model_payload = pickle.dumps(envelope, protocol=pickle.HIGHEST_PROTOCOL)
model_sha256 = hashlib.sha256(model_payload).hexdigest()
metadata = ArtifactMetadata(
artifact_format_version=ARTIFACT_FORMAT_VERSION,
model_version=model_version,
created_at_utc=datetime.now(UTC).isoformat(),
source_revision=source_revision,
training_data_sha256=training_data_sha256,
model_sha256=model_sha256,
input_columns=tuple(INFERENCE_COLUMNS),
python_version=platform.python_version(),
random_state=random_state,
n_jobs=n_jobs,
label_model=label_model,
severity_model=severity_model,
ood_absolute_threshold_mv=OOD_OK_TO_UNKNOWN_THRESHOLD_MV,
ood_ratio_threshold=OOD_OK_TO_UNKNOWN_RATIO_THRESHOLD,
runtime_versions=_runtime_versions(),
)
directory.mkdir(parents=True, exist_ok=True)
_atomic_write(directory / MODEL_FILENAME, model_payload)
manifest_payload = json.dumps(
metadata.to_dict(), ensure_ascii=False, indent=2, sort_keys=True
).encode("utf-8") + b"\n"
_atomic_write(directory / MANIFEST_FILENAME, manifest_payload)
return metadata
def load_model_artifact(
directory: Path, *, expected_model_version: str | None = None
) -> LoadedModelArtifact:
manifest_path = directory / MANIFEST_FILENAME
model_path = directory / MODEL_FILENAME
try:
manifest_payload = json.loads(manifest_path.read_text(encoding="utf-8"))
except FileNotFoundError as exc:
raise ModelArtifactError(f"Model manifest is missing: {manifest_path}") from exc
except (OSError, json.JSONDecodeError) as exc:
raise ModelArtifactError("Model manifest cannot be read.") from exc
if not isinstance(manifest_payload, dict):
raise ModelArtifactError("Model manifest must be a JSON object.")
metadata = ArtifactMetadata.from_dict(manifest_payload)
if metadata.artifact_format_version != ARTIFACT_FORMAT_VERSION:
raise ModelArtifactError(
"Unsupported model artifact format: "
f"{metadata.artifact_format_version}."
)
if expected_model_version and metadata.model_version != expected_model_version:
raise ModelArtifactError(
f"Expected model {expected_model_version}, received {metadata.model_version}."
)
if metadata.input_columns != tuple(INFERENCE_COLUMNS):
raise ModelArtifactError("Model input schema does not match this application.")
expected_python = metadata.python_version.split(".")[:2]
installed_python = platform.python_version().split(".")[:2]
if expected_python != installed_python:
raise ModelArtifactError(
"Model Python version does not match the runtime. "
f"expected={metadata.python_version}, installed={platform.python_version()}"
)
current_versions = _runtime_versions()
if metadata.runtime_versions != current_versions:
raise ModelArtifactError(
"Model runtime versions do not match installed dependencies. "
f"expected={metadata.runtime_versions}, installed={current_versions}"
)
try:
model_payload = model_path.read_bytes()
except OSError as exc:
raise ModelArtifactError(f"Model file cannot be read: {model_path}") from exc
actual_sha256 = hashlib.sha256(model_payload).hexdigest()
if actual_sha256 != metadata.model_sha256:
raise ModelArtifactError("Model checksum verification failed.")
try:
envelope = pickle.loads(model_payload) # noqa: S301 - trusted, checksummed release artifact
except Exception as exc:
raise ModelArtifactError("Model payload cannot be deserialized.") from exc
if not isinstance(envelope, dict):
raise ModelArtifactError("Model payload has an invalid envelope.")
if envelope.get("artifact_format_version") != ARTIFACT_FORMAT_VERSION:
raise ModelArtifactError("Model payload format does not match the manifest.")
if envelope.get("model_version") != metadata.model_version:
raise ModelArtifactError("Model payload version does not match the manifest.")
models = envelope.get("models")
if not isinstance(models, DiagnosticModels):
raise ModelArtifactError("Model payload has an unexpected object type.")
return LoadedModelArtifact(models=models, metadata=metadata)
__all__ = [
"ARTIFACT_FORMAT_VERSION",
"MANIFEST_FILENAME",
"MODEL_FILENAME",
"ArtifactMetadata",
"LoadedModelArtifact",
"ModelArtifactError",
"load_model_artifact",
"save_model_artifact",
"sha256_file",
]

View File

@ -4,14 +4,11 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from benchmark_grouped import ( FREQ_COLS = [f"mV_{frequency}" for frequency in range(21)]
FAULT_LABELS, LABELS = ["ok", "zakoksowany", "lejacy", "pompa", "iglica", "unknown"]
FREQ_COLS, FAULT_LABELS = ["zakoksowany", "lejacy", "pompa", "iglica"]
LABELS, SEVERITIES = ["male", "srednie", "duze"]
NOT_APPLICABLE, NOT_APPLICABLE = "nie_dotyczy"
SEVERITIES,
)
ALLOWED_ENGINE_SIZES = (8, 12, 16) ALLOWED_ENGINE_SIZES = (8, 12, 16)
MAX_UPLOAD_BYTES = 10 * 1024 * 1024 MAX_UPLOAD_BYTES = 10 * 1024 * 1024
MAX_MISSING_FRACTION_PER_CYLINDER = 0.50 MAX_MISSING_FRACTION_PER_CYLINDER = 0.50

171
engin/features.py Normal file
View File

@ -0,0 +1,171 @@
"""Stable feature transformers shared by model training and runtime inference."""
from __future__ import annotations
from typing import Iterable
import numpy as np
import pandas as pd
from .config import FAULT_LABELS, FREQ_COLS
FEATURE_SETS = ("raw", "deviation", "all")
class SeverityFeatures:
"""Create engine-relative spectral features without using labels.
Engine-relative values use a leave-one-cylinder-out median reference. Input
to ``transform`` must therefore contain every cylinder of each engine.
``diagnostic_label`` is optional and may contain only a model-predicted
label at validation or inference time.
"""
def __init__(self, feature_set: str, include_fault_type: bool = False) -> None:
if feature_set not in FEATURE_SETS:
raise ValueError(f"Unknown feature_set={feature_set!r}")
self.feature_set = feature_set
self.include_fault_type = include_fault_type
def fit(
self, X: pd.DataFrame, y: Iterable[str] | None = None
) -> "SeverityFeatures":
self._validate(X)
spectra = X[FREQ_COLS].apply(pd.to_numeric, errors="coerce")
fallback = spectra.median(axis=0).to_numpy(dtype=float)
if np.isnan(fallback).any():
raise ValueError("A frequency column is entirely NaN in training.")
self.fallback_medians_ = fallback
return self
def transform(self, X: pd.DataFrame) -> np.ndarray:
self._validate(X)
if not hasattr(self, "fallback_medians_"):
raise RuntimeError("SeverityFeatures must be fitted before transform().")
raw = self._clean_spectra(X)
reference = self._leave_one_out_engine_median(
raw, X["engine_id"].to_numpy()
)
relative = raw - reference
absolute_relative = np.abs(relative)
ratio_delta = raw / np.maximum(np.abs(reference), 1e-6) - 1.0
if self.feature_set == "raw":
features = raw
elif self.feature_set == "deviation":
features = np.hstack(
[
relative,
absolute_relative,
ratio_delta,
self._summary_features(raw, relative, ratio_delta),
]
)
else:
features = np.hstack(
[
raw,
relative,
absolute_relative,
ratio_delta,
self._summary_features(raw, relative, ratio_delta),
]
)
if self.include_fault_type:
if "diagnostic_label" not in X.columns:
raise ValueError(
"diagnostic_label is required when include_fault_type=True"
)
labels = X["diagnostic_label"].to_numpy(dtype=object)
one_hot = np.column_stack(
[labels == label for label in FAULT_LABELS]
).astype(float)
features = np.hstack([features, one_hot])
if np.isnan(features).any() or np.isinf(features).any():
raise ValueError("Severity features contain NaN or infinity.")
return features
def fit_transform(
self, X: pd.DataFrame, y: Iterable[str] | None = None
) -> np.ndarray:
return self.fit(X).transform(X)
def _clean_spectra(self, X: pd.DataFrame) -> np.ndarray:
spectra = X[FREQ_COLS].apply(pd.to_numeric, errors="coerce")
spectra = spectra.interpolate(axis=1, limit_direction="both")
spectra = spectra.fillna(pd.Series(self.fallback_medians_, index=FREQ_COLS))
return spectra.to_numpy(dtype=float)
@staticmethod
def _leave_one_out_engine_median(
raw: np.ndarray, groups: np.ndarray
) -> np.ndarray:
reference = np.empty_like(raw)
for engine_id in pd.unique(groups):
positions = np.flatnonzero(groups == engine_id)
if len(positions) < 2:
raise ValueError(
f"Engine {engine_id!r} has fewer than two cylinders in transform()."
)
engine_values = raw[positions]
for local_position, global_position in enumerate(positions):
keep = np.arange(len(positions)) != local_position
reference[global_position] = np.median(engine_values[keep], axis=0)
return reference
@staticmethod
def _summary_features(
raw: np.ndarray, relative: np.ndarray, ratio_delta: np.ndarray
) -> np.ndarray:
absolute_relative = np.abs(relative)
gradients = np.diff(raw, axis=1)
frequency = np.arange(raw.shape[1], dtype=float)
centered_frequency = frequency - frequency.mean()
slope = raw @ centered_frequency / np.sum(centered_frequency**2)
if hasattr(np, "trapezoid"):
spectral_auc = np.trapezoid(raw, axis=1)
else: # pragma: no cover - compatibility with NumPy 1.x
spectral_auc = np.trapz(raw, axis=1)
columns = [
raw.mean(axis=1),
raw.std(axis=1),
raw.min(axis=1),
raw.max(axis=1),
np.ptp(raw, axis=1),
spectral_auc,
raw.argmax(axis=1) / 20.0,
raw.argmin(axis=1) / 20.0,
slope,
np.abs(gradients).mean(axis=1),
np.abs(gradients).max(axis=1),
relative.mean(axis=1),
relative.std(axis=1),
relative.min(axis=1),
relative.max(axis=1),
absolute_relative.mean(axis=1),
absolute_relative.max(axis=1),
np.sqrt(np.mean(relative**2, axis=1)),
ratio_delta.mean(axis=1),
ratio_delta.std(axis=1),
]
for start, stop in ((0, 5), (5, 10), (10, 15), (15, 21)):
columns.extend(
[
raw[:, start:stop].mean(axis=1),
relative[:, start:stop].mean(axis=1),
absolute_relative[:, start:stop].mean(axis=1),
]
)
return np.column_stack(columns)
@staticmethod
def _validate(X: pd.DataFrame) -> None:
required = {"engine_id", *FREQ_COLS}
missing = sorted(required.difference(X.columns))
if missing:
raise ValueError(f"Missing columns: {missing}")
__all__ = ["FEATURE_SETS", "SeverityFeatures"]

205
engin/inference.py Normal file
View File

@ -0,0 +1,205 @@
"""Runtime-only prediction contract for the frozen ENGIN model."""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
import pandas as pd
from .config import FAULT_LABELS, FREQ_COLS, LABELS, NOT_APPLICABLE, SEVERITIES
OOD_OK_TO_UNKNOWN_THRESHOLD_MV = 7.25
OOD_OK_TO_UNKNOWN_RATIO_THRESHOLD = 2.5
SUBMISSION_COLUMNS = ["engine_id", "cylinder", "label", "severity"]
KEY_COLUMNS = ["engine_id", "cylinder"]
INFERENCE_COLUMNS = ["engine_id", "cylinder", "n_cylinders", *FREQ_COLS]
@dataclass
class DiagnosticModels:
label_pipeline: object
severity_transformer: object
severity_estimator: object
def apply_ood_override(
predicted_label: np.ndarray,
deviation_features: np.ndarray,
engine_ids: np.ndarray | pd.Series,
threshold_mv: float = OOD_OK_TO_UNKNOWN_THRESHOLD_MV,
threshold_ratio: float = OOD_OK_TO_UNKNOWN_RATIO_THRESHOLD,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""Turn an implausibly anomalous ``ok`` into ``unknown``."""
result = np.asarray(predicted_label, dtype=object).copy()
absolute_deviation = deviation_features[
:, len(FREQ_COLS) : 2 * len(FREQ_COLS)
]
anomaly_score = absolute_deviation.mean(axis=1)
ids = np.asarray(engine_ids, dtype=object)
if len(ids) != len(result):
raise ValueError("engine_ids must align with predicted labels.")
score_frame = pd.DataFrame({"engine_id": ids, "anomaly_score": anomaly_score})
engine_median = (
score_frame.groupby("engine_id", sort=False)["anomaly_score"]
.transform("median")
.to_numpy(dtype=float)
)
anomaly_ratio = anomaly_score / np.maximum(engine_median, 1e-6)
override = (
(result == "ok")
& (anomaly_score > threshold_mv)
& (anomaly_ratio > threshold_ratio)
)
result[override] = "unknown"
return result, override, anomaly_score, anomaly_ratio
def validate_inference_data(df: pd.DataFrame) -> None:
required = set(INFERENCE_COLUMNS)
missing = sorted(required.difference(df.columns))
if missing:
raise ValueError(f"Inference data is missing columns: {missing}")
if df.empty:
raise ValueError("Inference data is empty.")
if df[["engine_id", "cylinder", "n_cylinders"]].isna().any().any():
raise ValueError("Inference identifiers cannot contain NaN.")
if df.duplicated(KEY_COLUMNS).any():
raise ValueError("Duplicate engine_id + cylinder keys found in inference data.")
engine_sizes = df.groupby("engine_id").agg(
rows=("cylinder", "size"),
expected=("n_cylinders", "first"),
size_values=("n_cylinders", "nunique"),
unique_cylinders=("cylinder", "nunique"),
)
complete = (
(engine_sizes["rows"] == engine_sizes["expected"])
& (engine_sizes["unique_cylinders"] == engine_sizes["expected"])
& (engine_sizes["size_values"] == 1)
& (engine_sizes["rows"] >= 2)
)
if not complete.all():
bad = engine_sizes.index[~complete].tolist()
raise ValueError(f"Incomplete or inconsistent inference engines: {bad}")
def _prepare_labeled_frame(df: pd.DataFrame, labels: np.ndarray) -> pd.DataFrame:
result = df.copy()
result["diagnostic_label"] = labels
return result
def predict_test(
models: DiagnosticModels, test: pd.DataFrame
) -> tuple[pd.DataFrame, pd.DataFrame]:
validate_inference_data(test)
raw_model_label = models.label_pipeline.predict(test).astype(object)
label_features = models.label_pipeline.named_steps["features"].transform(test)
predicted_label, ood_override, anomaly_score, anomaly_ratio = apply_ood_override(
raw_model_label,
label_features,
test["engine_id"],
)
label_probabilities = models.label_pipeline.predict_proba(test)
raw_model_confidence = label_probabilities.max(axis=1)
label_confidence = raw_model_confidence.copy()
label_confidence[ood_override] = np.nan
ordered_probabilities = np.sort(label_probabilities, axis=1)
label_margin = ordered_probabilities[:, -1] - ordered_probabilities[:, -2]
test_with_labels = _prepare_labeled_frame(test, predicted_label)
severity_features = models.severity_transformer.transform(test_with_labels)
predicted_severity_all = models.severity_estimator.predict(
severity_features
).astype(object)
predicted_fault = np.isin(predicted_label, FAULT_LABELS)
emitted_severity = np.full(len(test), NOT_APPLICABLE, dtype=object)
emitted_severity[predicted_fault] = predicted_severity_all[predicted_fault]
severity_probabilities = models.severity_estimator.predict_proba(
severity_features
)
severity_confidence = np.full(len(test), np.nan, dtype=float)
severity_confidence[predicted_fault] = severity_probabilities.max(axis=1)[
predicted_fault
]
absolute_deviation = severity_features[:, len(FREQ_COLS) : 2 * len(FREQ_COLS)]
top_frequency_indices = np.argsort(absolute_deviation, axis=1)[:, -3:][:, ::-1]
top_frequencies = [
"|".join(str(int(index)) for index in row) for row in top_frequency_indices
]
submission = test[KEY_COLUMNS].copy()
submission["label"] = predicted_label
submission["severity"] = emitted_severity
diagnostics = submission.copy()
diagnostics["raw_model_label"] = raw_model_label
diagnostics["decision_source"] = np.where(
ood_override, "ood_override", "classifier"
)
diagnostics["label_confidence"] = label_confidence
diagnostics["raw_model_confidence"] = raw_model_confidence
diagnostics["label_margin"] = label_margin
diagnostics["severity_confidence"] = severity_confidence
diagnostics["anomaly_score_mean_abs_mv"] = anomaly_score
diagnostics["anomaly_ratio_to_engine_median"] = anomaly_ratio
diagnostics["ood_absolute_threshold_mv"] = OOD_OK_TO_UNKNOWN_THRESHOLD_MV
diagnostics["ood_ratio_threshold"] = OOD_OK_TO_UNKNOWN_RATIO_THRESHOLD
diagnostics["top_anomalous_frequencies_khz"] = top_frequencies
diagnostics["missing_spectral_cells"] = (
test[FREQ_COLS].isna().sum(axis=1).to_numpy(dtype=int)
)
return submission, diagnostics
def validate_submission(submission: pd.DataFrame, test: pd.DataFrame) -> None:
if submission.columns.tolist() != SUBMISSION_COLUMNS:
raise ValueError(
f"Submission columns must be exactly {SUBMISSION_COLUMNS}; "
f"received={submission.columns.tolist()}"
)
if len(submission) != len(test):
raise ValueError("Submission row count does not match test.csv.")
if submission.isna().any().any():
raise ValueError("Submission contains NaN.")
if submission.duplicated(KEY_COLUMNS).any():
raise ValueError("Submission contains duplicate engine_id + cylinder keys.")
if not submission[KEY_COLUMNS].reset_index(drop=True).equals(
test[KEY_COLUMNS].reset_index(drop=True)
):
raise ValueError("Submission keys or row order do not match test.csv.")
invalid_labels = sorted(set(submission["label"]).difference(LABELS))
if invalid_labels:
raise ValueError(f"Submission contains invalid labels: {invalid_labels}")
fault = submission["label"].isin(FAULT_LABELS)
invalid_fault_severity = sorted(
set(submission.loc[fault, "severity"]).difference(SEVERITIES)
)
if invalid_fault_severity:
raise ValueError(
f"Fault predictions contain invalid severity: {invalid_fault_severity}"
)
if not submission.loc[~fault, "severity"].eq(NOT_APPLICABLE).all():
raise ValueError("ok/unknown predictions must use severity=nie_dotyczy.")
__all__ = [
"DiagnosticModels",
"INFERENCE_COLUMNS",
"KEY_COLUMNS",
"OOD_OK_TO_UNKNOWN_RATIO_THRESHOLD",
"OOD_OK_TO_UNKNOWN_THRESHOLD_MV",
"SUBMISSION_COLUMNS",
"apply_ood_override",
"predict_test",
"validate_inference_data",
"validate_submission",
]

View File

@ -3,18 +3,14 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path
from typing import Protocol from typing import Protocol
import pandas as pd import pandas as pd
from final_pipeline import ( from .artifact import ArtifactMetadata, load_model_artifact
DiagnosticModels,
predict_test,
train_models,
validate_submission,
)
from .errors import InferenceError from .errors import InferenceError
from .inference import DiagnosticModels, predict_test, validate_submission
@dataclass(frozen=True) @dataclass(frozen=True)
@ -28,31 +24,31 @@ class PredictionModel(Protocol):
class SklearnPredictionModel: class SklearnPredictionModel:
"""Thin adapter around the frozen, validated competition pipeline.""" """Runtime adapter around a verified, pre-trained model artifact."""
def __init__(self, models: DiagnosticModels) -> None: def __init__(
self, models: DiagnosticModels, metadata: ArtifactMetadata | None = None
) -> None:
self._models = models self._models = models
self.metadata = metadata
@classmethod @classmethod
def train( def from_artifact(
cls, cls,
reference_frame: pd.DataFrame, directory: Path,
*, *,
random_state: int = 42, expected_model_version: str | None = None,
n_jobs: int = -1,
) -> "SklearnPredictionModel": ) -> "SklearnPredictionModel":
try: try:
models = train_models( artifact = load_model_artifact(
reference_frame, directory, expected_model_version=expected_model_version
random_state=random_state,
n_jobs=n_jobs,
) )
except Exception as exc: except Exception as exc:
raise InferenceError( raise InferenceError(
"Nie udało się przygotować modelu referencyjnego.", "Nie udało się załadować zweryfikowanego artefaktu modelu.",
hint="Sprawdź kompletność val.csv oraz zgodność wersji zależności.", hint="Sprawdź manifest, checksumę oraz zgodność wersji zależności.",
) from exc ) from exc
return cls(models) return cls(artifact.models, artifact.metadata)
def predict(self, frame: pd.DataFrame) -> PredictionBundle: def predict(self, frame: pd.DataFrame) -> PredictionBundle:
try: try:
@ -63,6 +59,13 @@ class SklearnPredictionModel:
"Model odrzucił pomiary podczas predykcji.", "Model odrzucił pomiary podczas predykcji.",
hint="Zweryfikuj kompletność silników oraz brak nietypowych wartości w widmie.", hint="Zweryfikuj kompletność silników oraz brak nietypowych wartości w widmie.",
) from exc ) from exc
diagnostics = diagnostics.copy()
diagnostics["model_version"] = (
self.metadata.model_version if self.metadata else "unversioned"
)
diagnostics["model_artifact_sha256"] = (
self.metadata.model_sha256 if self.metadata else "unavailable"
)
return PredictionBundle(submission, diagnostics) return PredictionBundle(submission, diagnostics)
@ -78,9 +81,9 @@ class PrecomputedPredictionModel:
if not frame[keys].reset_index(drop=True).equals(self._submission[keys]): if not frame[keys].reset_index(drop=True).equals(self._submission[keys]):
raise InferenceError( raise InferenceError(
"Tryb awaryjny obsługuje wyłącznie dołączony zestaw demonstracyjny.", "Tryb awaryjny obsługuje wyłącznie dołączony zestaw demonstracyjny.",
hint="Przywróć val.csv i uruchom ponownie aplikację, aby diagnozować własne pliki.", hint="Przywróć artefakt modelu i uruchom ponownie aplikację, aby diagnozować własne pliki.",
) )
return PredictionBundle( diagnostics = self._diagnostics.copy()
self._submission.copy(), diagnostics["model_version"] = "demo-precomputed"
self._diagnostics.copy(), diagnostics["model_artifact_sha256"] = "unavailable"
) return PredictionBundle(self._submission.copy(), diagnostics)

View File

@ -13,46 +13,47 @@ used because its benefit has not been established in grouped validation.
from __future__ import annotations from __future__ import annotations
import argparse import argparse
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
import numpy as np
import pandas as pd import pandas as pd
from sklearn.linear_model import LogisticRegression from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler from sklearn.preprocessing import StandardScaler
from benchmark_grouped import ( from benchmark_grouped import validate_data
FAULT_LABELS, from engin.config import FAULT_LABELS, LABELS, NOT_APPLICABLE, SEVERITIES
FREQ_COLS, from engin.features import SeverityFeatures
LABELS, from engin.inference import (
NOT_APPLICABLE, KEY_COLUMNS,
SEVERITIES, OOD_OK_TO_UNKNOWN_RATIO_THRESHOLD,
validate_data, OOD_OK_TO_UNKNOWN_THRESHOLD_MV,
DiagnosticModels,
apply_ood_override,
predict_test,
validate_inference_data,
validate_submission,
) )
from severity_benchmark import ( from severity_benchmark import (
CANDIDATE_BY_ID, CANDIDATE_BY_ID,
SeverityFeatures,
fit_candidate, fit_candidate,
predict_candidate,
prepare_labeled_frame, prepare_labeled_frame,
) )
__all__ = [
"DiagnosticModels",
"OOD_OK_TO_UNKNOWN_RATIO_THRESHOLD",
"OOD_OK_TO_UNKNOWN_THRESHOLD_MV",
"apply_ood_override",
"predict_test",
"run_pipeline",
"train_models",
"validate_inference_data",
"validate_submission",
]
LABEL_MODEL_NAME = "deviation_logistic_c10" LABEL_MODEL_NAME = "deviation_logistic_c10"
LABEL_FEATURE_SET = "deviation" LABEL_FEATURE_SET = "deviation"
SEVERITY_CANDIDATE_ID = "deviation_extra_trees_mf03" SEVERITY_CANDIDATE_ID = "deviation_extra_trees_mf03"
OOD_OK_TO_UNKNOWN_THRESHOLD_MV = 7.25
OOD_OK_TO_UNKNOWN_RATIO_THRESHOLD = 2.5
SUBMISSION_COLUMNS = ["engine_id", "cylinder", "label", "severity"]
KEY_COLUMNS = ["engine_id", "cylinder"]
@dataclass
class DiagnosticModels:
label_pipeline: object
severity_candidate: object
severity_transformer: object
severity_estimator: object
def make_final_label_pipeline(random_state: int = 42) -> Pipeline: def make_final_label_pipeline(random_state: int = 42) -> Pipeline:
@ -75,45 +76,6 @@ def make_final_label_pipeline(random_state: int = 42) -> Pipeline:
) )
def apply_ood_override(
predicted_label: np.ndarray,
deviation_features: np.ndarray,
engine_ids: np.ndarray | pd.Series,
threshold_mv: float = OOD_OK_TO_UNKNOWN_THRESHOLD_MV,
threshold_ratio: float = OOD_OK_TO_UNKNOWN_RATIO_THRESHOLD,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""Turn an implausibly anomalous ``ok`` into ``unknown``.
The absolute threshold was frozen after repeated grouped validation on
clean spectra and spectra with 5% masked measurements. A second guard
requires the cylinder to be anomalous relative to the median anomaly level
of its own engine. This prevents a globally noisy unit from being treated
as a collection of isolated OOD cylinders. The rule never changes a named
fault into another class.
"""
result = np.asarray(predicted_label, dtype=object).copy()
absolute_deviation = deviation_features[
:, len(FREQ_COLS) : 2 * len(FREQ_COLS)
]
anomaly_score = absolute_deviation.mean(axis=1)
ids = np.asarray(engine_ids, dtype=object)
if len(ids) != len(result):
raise ValueError("engine_ids must align with predicted labels.")
score_frame = pd.DataFrame({"engine_id": ids, "anomaly_score": anomaly_score})
engine_median = score_frame.groupby("engine_id", sort=False)[
"anomaly_score"
].transform("median").to_numpy(dtype=float)
anomaly_ratio = anomaly_score / np.maximum(engine_median, 1e-6)
override = (
(result == "ok")
& (anomaly_score > threshold_mv)
& (anomaly_ratio > threshold_ratio)
)
result[override] = "unknown"
return result, override, anomaly_score, anomaly_ratio
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--val", type=Path, default=Path("val.csv")) parser.add_argument("--val", type=Path, default=Path("val.csv"))
@ -133,35 +95,6 @@ def parse_args() -> argparse.Namespace:
return parser.parse_args() return parser.parse_args()
def validate_inference_data(df: pd.DataFrame) -> None:
required = {"engine_id", "cylinder", "n_cylinders", *FREQ_COLS}
missing = sorted(required.difference(df.columns))
if missing:
raise ValueError(f"Inference data is missing columns: {missing}")
if df.empty:
raise ValueError("Inference data is empty.")
if df[["engine_id", "cylinder", "n_cylinders"]].isna().any().any():
raise ValueError("Inference identifiers cannot contain NaN.")
if df.duplicated(KEY_COLUMNS).any():
raise ValueError("Duplicate engine_id + cylinder keys found in inference data.")
engine_sizes = df.groupby("engine_id").agg(
rows=("cylinder", "size"),
expected=("n_cylinders", "first"),
size_values=("n_cylinders", "nunique"),
unique_cylinders=("cylinder", "nunique"),
)
complete = (
(engine_sizes["rows"] == engine_sizes["expected"])
& (engine_sizes["unique_cylinders"] == engine_sizes["expected"])
& (engine_sizes["size_values"] == 1)
& (engine_sizes["rows"] >= 2)
)
if not complete.all():
bad = engine_sizes.index[~complete].tolist()
raise ValueError(f"Incomplete or inconsistent inference engines: {bad}")
def validate_sample_keys(sample: pd.DataFrame, test: pd.DataFrame) -> None: def validate_sample_keys(sample: pd.DataFrame, test: pd.DataFrame) -> None:
missing = sorted(set(KEY_COLUMNS).difference(sample.columns)) missing = sorted(set(KEY_COLUMNS).difference(sample.columns))
if missing: if missing:
@ -210,119 +143,11 @@ def train_models(
) )
return DiagnosticModels( return DiagnosticModels(
label_pipeline=label_pipeline, label_pipeline=label_pipeline,
severity_candidate=candidate,
severity_transformer=transformer, severity_transformer=transformer,
severity_estimator=estimator, severity_estimator=estimator,
) )
def predict_test(
models: DiagnosticModels, test: pd.DataFrame
) -> tuple[pd.DataFrame, pd.DataFrame]:
validate_inference_data(test)
raw_model_label = models.label_pipeline.predict(test).astype(object)
label_features = models.label_pipeline.named_steps["features"].transform(test)
predicted_label, ood_override, anomaly_score, anomaly_ratio = apply_ood_override(
raw_model_label,
label_features,
test["engine_id"],
)
label_probabilities = models.label_pipeline.predict_proba(test)
raw_model_confidence = label_probabilities.max(axis=1)
label_confidence = raw_model_confidence.copy()
# OOD is a deterministic safety rule, not a probabilistic classifier.
# Emitting NaN is more honest than manufacturing a probability-like score.
label_confidence[ood_override] = np.nan
ordered_probabilities = np.sort(label_probabilities, axis=1)
label_margin = ordered_probabilities[:, -1] - ordered_probabilities[:, -2]
test_with_labels = prepare_labeled_frame(test, predicted_label)
predicted_severity_all = predict_candidate(
models.severity_candidate,
models.severity_transformer,
models.severity_estimator,
test_with_labels,
predicted_label,
).astype(object)
predicted_fault = np.isin(predicted_label, FAULT_LABELS)
emitted_severity = np.full(len(test), NOT_APPLICABLE, dtype=object)
emitted_severity[predicted_fault] = predicted_severity_all[predicted_fault]
severity_features = models.severity_transformer.transform(test_with_labels)
severity_probabilities = models.severity_estimator.predict_proba(severity_features)
severity_confidence = np.full(len(test), np.nan, dtype=float)
severity_confidence[predicted_fault] = severity_probabilities.max(axis=1)[
predicted_fault
]
# The selected deviation representation starts with 21 signed deviations
# followed by their 21 absolute values. Reuse them for UI explainability.
absolute_deviation = severity_features[:, len(FREQ_COLS) : 2 * len(FREQ_COLS)]
top_frequency_indices = np.argsort(absolute_deviation, axis=1)[:, -3:][:, ::-1]
top_frequencies = [
"|".join(str(int(index)) for index in row)
for row in top_frequency_indices
]
submission = test[KEY_COLUMNS].copy()
submission["label"] = predicted_label
submission["severity"] = emitted_severity
diagnostics = submission.copy()
diagnostics["raw_model_label"] = raw_model_label
diagnostics["decision_source"] = np.where(
ood_override, "ood_override", "classifier"
)
diagnostics["label_confidence"] = label_confidence
diagnostics["raw_model_confidence"] = raw_model_confidence
diagnostics["label_margin"] = label_margin
diagnostics["severity_confidence"] = severity_confidence
diagnostics["anomaly_score_mean_abs_mv"] = anomaly_score
diagnostics["anomaly_ratio_to_engine_median"] = anomaly_ratio
diagnostics["ood_absolute_threshold_mv"] = OOD_OK_TO_UNKNOWN_THRESHOLD_MV
diagnostics["ood_ratio_threshold"] = OOD_OK_TO_UNKNOWN_RATIO_THRESHOLD
diagnostics["top_anomalous_frequencies_khz"] = top_frequencies
diagnostics["missing_spectral_cells"] = (
test[FREQ_COLS].isna().sum(axis=1).to_numpy(dtype=int)
)
return submission, diagnostics
def validate_submission(submission: pd.DataFrame, test: pd.DataFrame) -> None:
if submission.columns.tolist() != SUBMISSION_COLUMNS:
raise ValueError(
f"Submission columns must be exactly {SUBMISSION_COLUMNS}; "
f"received={submission.columns.tolist()}"
)
if len(submission) != len(test):
raise ValueError("Submission row count does not match test.csv.")
if submission.isna().any().any():
raise ValueError("Submission contains NaN.")
if submission.duplicated(KEY_COLUMNS).any():
raise ValueError("Submission contains duplicate engine_id + cylinder keys.")
if not submission[KEY_COLUMNS].reset_index(drop=True).equals(
test[KEY_COLUMNS].reset_index(drop=True)
):
raise ValueError("Submission keys or row order do not match test.csv.")
invalid_labels = sorted(set(submission["label"]).difference(LABELS))
if invalid_labels:
raise ValueError(f"Submission contains invalid labels: {invalid_labels}")
fault = submission["label"].isin(FAULT_LABELS)
invalid_fault_severity = sorted(
set(submission.loc[fault, "severity"]).difference(SEVERITIES)
)
if invalid_fault_severity:
raise ValueError(
f"Fault predictions contain invalid severity: {invalid_fault_severity}"
)
if not submission.loc[~fault, "severity"].eq(NOT_APPLICABLE).all():
raise ValueError("ok/unknown predictions must use severity=nie_dotyczy.")
def run_pipeline( def run_pipeline(
val: pd.DataFrame, val: pd.DataFrame,
test: pd.DataFrame, test: pd.DataFrame,

View File

@ -1,6 +1,50 @@
altair==6.2.2
anyio==4.14.2
attrs==26.1.0
blinker==1.9.0
certifi==2026.7.22
charset-normalizer==3.5.1
click==8.4.2
contourpy==1.3.3
cycler==0.12.1
fonttools==4.63.0
h11==0.16.0
httptools==0.8.0
idna==3.19
itsdangerous==2.2.0
Jinja2==3.1.6
joblib==1.5.3
jsonschema==4.26.0
jsonschema-specifications==2025.9.1
kiwisolver==1.5.0
MarkupSafe==3.0.3
matplotlib==3.10.8 matplotlib==3.10.8
narwhals==2.25.0
numpy==2.3.5 numpy==2.3.5
packaging==26.3
pandas==2.2.3 pandas==2.2.3
pillow==12.3.0
plotly==6.9.0 plotly==6.9.0
protobuf==7.36.0
pyarrow==25.0.1
pydeck==0.9.3
pyparsing==3.3.2
python-dateutil==2.9.0.post0
python-multipart==0.0.32
pytz==2026.3.post1
referencing==0.37.0
requests==2.34.2
rpds-py==2026.6.3
scikit-learn==1.8.0 scikit-learn==1.8.0
scipy==1.18.1
six==1.17.0
starlette==1.6.0
streamlit==1.62.0 streamlit==1.62.0
threadpoolctl==3.6.0
toml==0.10.2
typing_extensions==4.16.0
tzdata==2026.3
urllib3==2.7.0
uvicorn==0.52.4
watchdog==6.0.0
websockets==16.1.1

1
scripts/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Operational scripts for building and verifying ENGIN releases."""

View File

@ -0,0 +1,130 @@
"""Build or verify the versioned ENGIN production model artifact."""
from __future__ import annotations
import argparse
from pathlib import Path
import pandas as pd
from pandas.testing import assert_frame_equal
from engin.artifact import (
LoadedModelArtifact,
load_model_artifact,
save_model_artifact,
sha256_file,
)
from engin.inference import predict_test, validate_submission
from final_pipeline import (
LABEL_MODEL_NAME,
SEVERITY_CANDIDATE_ID,
train_models,
)
DEFAULT_MODEL_VERSION = "engin-2026.08.25-1"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--val", type=Path, default=Path("val.csv"))
parser.add_argument("--test", type=Path, default=Path("test.csv"))
parser.add_argument(
"--expected-submission", type=Path, default=Path("predictions.csv")
)
parser.add_argument(
"--expected-diagnostics",
type=Path,
default=Path("prediction_diagnostics.csv"),
)
parser.add_argument("--model-version", default=DEFAULT_MODEL_VERSION)
parser.add_argument("--source-revision", default="unknown")
parser.add_argument("--random-state", type=int, default=42)
parser.add_argument("--n-jobs", type=int, default=-1)
parser.add_argument("--artifact-dir", type=Path)
parser.add_argument(
"--verify-only",
action="store_true",
help="Load and verify the existing artifact without retraining.",
)
return parser.parse_args()
def _artifact_dir(args: argparse.Namespace) -> Path:
return args.artifact_dir or Path("artifacts") / args.model_version
def build_artifact(args: argparse.Namespace) -> LoadedModelArtifact:
artifact_dir = _artifact_dir(args)
if not args.verify_only:
val = pd.read_csv(args.val).reset_index(drop=True)
models = train_models(
val,
random_state=args.random_state,
n_jobs=args.n_jobs,
)
save_model_artifact(
models,
artifact_dir,
model_version=args.model_version,
source_revision=args.source_revision,
training_data_sha256=sha256_file(args.val),
random_state=args.random_state,
n_jobs=args.n_jobs,
label_model=LABEL_MODEL_NAME,
severity_model=SEVERITY_CANDIDATE_ID,
)
return load_model_artifact(
artifact_dir,
expected_model_version=args.model_version,
)
def verify_predictions(
artifact: LoadedModelArtifact,
*,
test_path: Path,
expected_submission_path: Path,
expected_diagnostics_path: Path,
) -> None:
test = pd.read_csv(test_path).reset_index(drop=True)
submission, diagnostics = predict_test(artifact.models, test)
validate_submission(submission, test)
expected_submission = pd.read_csv(expected_submission_path).reset_index(drop=True)
expected_diagnostics = pd.read_csv(expected_diagnostics_path).reset_index(drop=True)
assert_frame_equal(
submission,
expected_submission,
check_dtype=False,
check_exact=True,
)
assert_frame_equal(
diagnostics,
expected_diagnostics,
check_dtype=False,
check_exact=False,
rtol=1e-12,
atol=1e-12,
)
def main() -> None:
args = parse_args()
artifact = build_artifact(args)
verify_predictions(
artifact,
test_path=args.test,
expected_submission_path=args.expected_submission,
expected_diagnostics_path=args.expected_diagnostics,
)
metadata = artifact.metadata
print(f"Artifact verified: {_artifact_dir(args).resolve()}")
print(f"Model version: {metadata.model_version}")
print(f"Model SHA-256: {metadata.model_sha256}")
print(f"Training data SHA-256: {metadata.training_data_sha256}")
print(f"Python: {metadata.python_version} | inference n_jobs: {metadata.n_jobs}")
print("Predictions: exact 600-row regression match")
if __name__ == "__main__":
main()

View File

@ -1,5 +1,7 @@
#!/usr/bin/env sh #!/usr/bin/env sh
set -eu set -eu
python -m py_compile app.py engin/*.py tests/*.py python -m compileall -q app.py engin tests final_pipeline.py scripts
python -m pip check
python -m unittest discover -v python -m unittest discover -v
python -m scripts.build_model_artifact --verify-only

View File

@ -32,6 +32,7 @@ from sklearn.pipeline import make_pipeline as sklearn_pipeline
from sklearn.preprocessing import StandardScaler from sklearn.preprocessing import StandardScaler
from sklearn.svm import SVC from sklearn.svm import SVC
from engin.features import FEATURE_SETS, SeverityFeatures
from benchmark_grouped import ( from benchmark_grouped import (
FAULT_LABELS, FAULT_LABELS,
FREQ_COLS, FREQ_COLS,
@ -50,7 +51,6 @@ from robustness_grouped import mask_spectral_cells, missing_scenario_name
DEFAULT_SEEDS = [7, 21, 42, 77, 123] DEFAULT_SEEDS = [7, 21, 42, 77, 123]
SEVERITY_TO_INT = {"male": 0, "srednie": 1, "duze": 2} SEVERITY_TO_INT = {"male": 0, "srednie": 1, "duze": 2}
INT_TO_SEVERITY = np.asarray(SEVERITIES, dtype=object) INT_TO_SEVERITY = np.asarray(SEVERITIES, dtype=object)
FEATURE_SETS = ("raw", "deviation", "all")
@dataclass(frozen=True) @dataclass(frozen=True)
@ -91,146 +91,6 @@ CANDIDATES = [
CANDIDATE_BY_ID = {candidate.candidate_id: candidate for candidate in CANDIDATES} CANDIDATE_BY_ID = {candidate.candidate_id: candidate for candidate in CANDIDATES}
class SeverityFeatures:
"""Create severity-oriented features without using validation labels.
Engine-relative values use a leave-one-cylinder-out median reference. Input
to ``transform`` must therefore contain every cylinder of each engine.
``diagnostic_label`` is optional and may contain only the model-predicted
label at validation or test time.
"""
def __init__(self, feature_set: str, include_fault_type: bool = False) -> None:
if feature_set not in FEATURE_SETS:
raise ValueError(f"Unknown feature_set={feature_set!r}")
self.feature_set = feature_set
self.include_fault_type = include_fault_type
def fit(
self, X: pd.DataFrame, y: Iterable[str] | None = None
) -> "SeverityFeatures":
self._validate(X)
spectra = X[FREQ_COLS].apply(pd.to_numeric, errors="coerce")
fallback = spectra.median(axis=0).to_numpy(dtype=float)
if np.isnan(fallback).any():
raise ValueError("A frequency column is entirely NaN in training.")
self.fallback_medians_ = fallback
return self
def transform(self, X: pd.DataFrame) -> np.ndarray:
self._validate(X)
if not hasattr(self, "fallback_medians_"):
raise RuntimeError("SeverityFeatures must be fitted before transform().")
raw = self._clean_spectra(X)
reference = self._leave_one_out_engine_median(raw, X["engine_id"].to_numpy())
relative = raw - reference
absolute_relative = np.abs(relative)
ratio_delta = raw / np.maximum(np.abs(reference), 1e-6) - 1.0
if self.feature_set == "raw":
features = raw
elif self.feature_set == "deviation":
features = np.hstack(
[relative, absolute_relative, ratio_delta, self._summary_features(raw, relative, ratio_delta)]
)
else:
features = np.hstack(
[raw, relative, absolute_relative, ratio_delta, self._summary_features(raw, relative, ratio_delta)]
)
if self.include_fault_type:
if "diagnostic_label" not in X.columns:
raise ValueError("diagnostic_label is required when include_fault_type=True")
labels = X["diagnostic_label"].to_numpy(dtype=object)
one_hot = np.column_stack([labels == label for label in FAULT_LABELS]).astype(float)
features = np.hstack([features, one_hot])
if np.isnan(features).any() or np.isinf(features).any():
raise ValueError("Severity features contain NaN or infinity.")
return features
def fit_transform(
self, X: pd.DataFrame, y: Iterable[str] | None = None
) -> np.ndarray:
return self.fit(X).transform(X)
def _clean_spectra(self, X: pd.DataFrame) -> np.ndarray:
spectra = X[FREQ_COLS].apply(pd.to_numeric, errors="coerce")
spectra = spectra.interpolate(axis=1, limit_direction="both")
spectra = spectra.fillna(pd.Series(self.fallback_medians_, index=FREQ_COLS))
return spectra.to_numpy(dtype=float)
@staticmethod
def _leave_one_out_engine_median(raw: np.ndarray, groups: np.ndarray) -> np.ndarray:
reference = np.empty_like(raw)
for engine_id in pd.unique(groups):
positions = np.flatnonzero(groups == engine_id)
if len(positions) < 2:
raise ValueError(
f"Engine {engine_id!r} has fewer than two cylinders in transform()."
)
engine_values = raw[positions]
for local_position, global_position in enumerate(positions):
keep = np.arange(len(positions)) != local_position
reference[global_position] = np.median(engine_values[keep], axis=0)
return reference
@staticmethod
def _summary_features(
raw: np.ndarray, relative: np.ndarray, ratio_delta: np.ndarray
) -> np.ndarray:
absolute_relative = np.abs(relative)
gradients = np.diff(raw, axis=1)
frequency = np.arange(raw.shape[1], dtype=float)
centered_frequency = frequency - frequency.mean()
slope = raw @ centered_frequency / np.sum(centered_frequency**2)
# ``np.trapezoid`` was added after NumPy 1.26, which is still allowed by
# requirements.txt. Keep the fresh-install path compatible with both
# NumPy 1.x and 2.x.
if hasattr(np, "trapezoid"):
spectral_auc = np.trapezoid(raw, axis=1)
else: # pragma: no cover - exercised only with NumPy 1.x
spectral_auc = np.trapz(raw, axis=1)
columns = [
raw.mean(axis=1),
raw.std(axis=1),
raw.min(axis=1),
raw.max(axis=1),
np.ptp(raw, axis=1),
spectral_auc,
raw.argmax(axis=1) / 20.0,
raw.argmin(axis=1) / 20.0,
slope,
np.abs(gradients).mean(axis=1),
np.abs(gradients).max(axis=1),
relative.mean(axis=1),
relative.std(axis=1),
relative.min(axis=1),
relative.max(axis=1),
absolute_relative.mean(axis=1),
absolute_relative.max(axis=1),
np.sqrt(np.mean(relative**2, axis=1)),
ratio_delta.mean(axis=1),
ratio_delta.std(axis=1),
]
for start, stop in ((0, 5), (5, 10), (10, 15), (15, 21)):
columns.extend(
[
raw[:, start:stop].mean(axis=1),
relative[:, start:stop].mean(axis=1),
absolute_relative[:, start:stop].mean(axis=1),
]
)
return np.column_stack(columns)
@staticmethod
def _validate(X: pd.DataFrame) -> None:
required = {"engine_id", *FREQ_COLS}
missing = sorted(required.difference(X.columns))
if missing:
raise ValueError(f"Missing columns: {missing}")
class OrdinalRidge(BaseEstimator): class OrdinalRidge(BaseEstimator):
"""Weighted ridge regression rounded to the three ordered severities.""" """Weighted ridge regression rounded to the three ordered severities."""

View File

@ -0,0 +1,114 @@
from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
import pandas as pd
from pandas.testing import assert_frame_equal
import app
from engin.artifact import (
MANIFEST_FILENAME,
MODEL_FILENAME,
ModelArtifactError,
load_model_artifact,
save_model_artifact,
)
from engin.inference import DiagnosticModels
from engin.model import SklearnPredictionModel
ROOT = Path(__file__).resolve().parents[1]
MODEL_VERSION = "engin-2026.08.25-1"
ARTIFACT_DIR = ROOT / "artifacts" / MODEL_VERSION
def _save_fake_artifact(directory: Path) -> None:
save_model_artifact(
DiagnosticModels("label", "transformer", "estimator"),
directory,
model_version="test-v1",
source_revision="unit-test",
training_data_sha256="a" * 64,
random_state=42,
n_jobs=1,
label_model="fake-label",
severity_model="fake-severity",
)
class ModelArtifactContractTests(unittest.TestCase):
def test_round_trip_preserves_metadata_and_models(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
directory = Path(temporary_directory)
_save_fake_artifact(directory)
loaded = load_model_artifact(
directory, expected_model_version="test-v1"
)
self.assertEqual(loaded.metadata.model_version, "test-v1")
self.assertEqual(loaded.models.label_pipeline, "label")
def test_corrupt_model_is_rejected_before_deserialization(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
directory = Path(temporary_directory)
_save_fake_artifact(directory)
with (directory / MODEL_FILENAME).open("ab") as handle:
handle.write(b"corruption")
with self.assertRaisesRegex(ModelArtifactError, "checksum"):
load_model_artifact(directory)
def test_runtime_version_mismatch_is_rejected(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
directory = Path(temporary_directory)
_save_fake_artifact(directory)
manifest_path = directory / MANIFEST_FILENAME
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["runtime_versions"]["scikit-learn"] = "0.0-invalid"
manifest_path.write_text(
json.dumps(manifest), encoding="utf-8"
)
with self.assertRaisesRegex(ModelArtifactError, "runtime versions"):
load_model_artifact(directory)
class ShippedArtifactTests(unittest.TestCase):
def test_artifact_reproduces_frozen_submission(self) -> None:
model = SklearnPredictionModel.from_artifact(
ARTIFACT_DIR,
expected_model_version=MODEL_VERSION,
)
test = pd.read_csv(ROOT / "test.csv").reset_index(drop=True)
expected = pd.read_csv(ROOT / "predictions.csv").reset_index(drop=True)
actual = model.predict(test).submission
assert_frame_equal(actual, expected, check_dtype=False, check_exact=True)
def test_diagnostics_identify_model_release(self) -> None:
model = SklearnPredictionModel.from_artifact(
ARTIFACT_DIR,
expected_model_version=MODEL_VERSION,
)
test = pd.read_csv(ROOT / "test.csv").reset_index(drop=True)
diagnostics = model.predict(test).diagnostics
self.assertEqual(set(diagnostics["model_version"]), {MODEL_VERSION})
self.assertEqual(
set(diagnostics["model_artifact_sha256"]),
{model.metadata.model_sha256},
)
def test_application_startup_does_not_read_training_data(self) -> None:
app.build_dependencies.clear()
with patch.object(
app.PandasCsvReader,
"read_path",
side_effect=AssertionError("runtime attempted to read training data"),
):
dependencies = app.build_dependencies()
app.build_dependencies.clear()
self.assertTrue(dependencies.live_inference)
self.assertEqual(dependencies.model_version, MODEL_VERSION)
if __name__ == "__main__":
unittest.main(verbosity=2)