Compare commits
11 Commits
final-prod
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5251e9629f | |||
| 2eb1e293c8 | |||
| 9032c49993 | |||
| dab5264ed7 | |||
| 7159c01123 | |||
| 82ab439452 | |||
| 5ebfdc389a | |||
| 8e6dcb750d | |||
| 8981ae0a00 | |||
| f2df616285 | |||
| 0fc96c6232 |
@ -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
|
||||||
|
|||||||
133
.gitea/workflows/ci.yaml
Normal file
133
.gitea/workflows/ci.yaml
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
name: ENGIN CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- prod-hardening
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
production-check:
|
||||||
|
name: Build, test and smoke
|
||||||
|
runs-on: engin-ci
|
||||||
|
timeout-minutes: 20
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Clean previous CI resources
|
||||||
|
run: |
|
||||||
|
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:test 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: Build test image
|
||||||
|
run: docker build --target test --tag engin-console:test .
|
||||||
|
|
||||||
|
- name: Run Ruff
|
||||||
|
run: |
|
||||||
|
docker run --rm \
|
||||||
|
--entrypoint sh \
|
||||||
|
engin-console:test \
|
||||||
|
-c "python -m pip install --quiet \
|
||||||
|
--disable-pip-version-check \
|
||||||
|
--root-user-action=ignore \
|
||||||
|
ruff==0.16.4 &&
|
||||||
|
ruff check --no-cache \
|
||||||
|
app.py engin tests final_pipeline.py ml_polish_benchmark.py scripts"
|
||||||
|
|
||||||
|
- name: Check installed dependencies
|
||||||
|
run: |
|
||||||
|
docker run --rm \
|
||||||
|
--entrypoint python \
|
||||||
|
engin-console:test \
|
||||||
|
-m pip check
|
||||||
|
|
||||||
|
- name: Run test suite
|
||||||
|
run: |
|
||||||
|
docker run --rm \
|
||||||
|
--entrypoint python \
|
||||||
|
engin-console:test \
|
||||||
|
-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
|
||||||
|
run: |
|
||||||
|
docker run --rm \
|
||||||
|
--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 production image contract
|
||||||
|
run: |
|
||||||
|
docker run --rm \
|
||||||
|
--entrypoint sh \
|
||||||
|
engin-console:ci \
|
||||||
|
-c "test \"\$(id -u)\" = 10001 &&
|
||||||
|
test \"\$(id -g)\" = 10001 &&
|
||||||
|
test ! -w /app &&
|
||||||
|
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
|
||||||
|
run: |
|
||||||
|
docker run -d \
|
||||||
|
--name engin-ci-app \
|
||||||
|
--read-only \
|
||||||
|
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
|
||||||
|
--cap-drop ALL \
|
||||||
|
--security-opt no-new-privileges:true \
|
||||||
|
--pids-limit 256 \
|
||||||
|
--memory 1g \
|
||||||
|
--cpus 2 \
|
||||||
|
engin-console:ci
|
||||||
|
|
||||||
|
- name: Verify runtime isolation
|
||||||
|
run: |
|
||||||
|
test "$(docker inspect --format '{{.HostConfig.ReadonlyRootfs}}' engin-ci-app)" = true
|
||||||
|
test "$(docker inspect --format '{{.HostConfig.PidsLimit}}' engin-ci-app)" = 256
|
||||||
|
test "$(docker inspect --format '{{.HostConfig.Memory}}' engin-ci-app)" = 1073741824
|
||||||
|
test "$(docker inspect --format '{{.HostConfig.NanoCpus}}' engin-ci-app)" = 2000000000
|
||||||
|
|
||||||
|
- name: Verify Streamlit health
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
attempt=1
|
||||||
|
while [ "$attempt" -le 30 ]; do
|
||||||
|
if docker exec engin-ci-app python -c \
|
||||||
|
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8501/_stcore/health', timeout=3).read()"; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
docker logs engin-ci-app
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Cleanup
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
docker logs 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:test 2>/dev/null || true
|
||||||
@ -10,7 +10,11 @@ font = "sans-serif"
|
|||||||
showErrorDetails = "none"
|
showErrorDetails = "none"
|
||||||
toolbarMode = "minimal"
|
toolbarMode = "minimal"
|
||||||
|
|
||||||
|
[browser]
|
||||||
|
gatherUsageStats = false
|
||||||
|
|
||||||
[server]
|
[server]
|
||||||
headless = true
|
headless = true
|
||||||
maxUploadSize = 10
|
maxUploadSize = 10
|
||||||
runOnSave = false
|
runOnSave = false
|
||||||
|
fileWatcherType = "none"
|
||||||
|
|||||||
34
Dockerfile
34
Dockerfile
@ -1,4 +1,7 @@
|
|||||||
FROM python:3.12-slim
|
ARG PYTHON_IMAGE=python:3.12-slim@sha256:7a8b475003c4fe15a2cd4e55e5cfc2f3560bdc9333d624f24cdd6d4340fd7a17
|
||||||
|
ARG APP_UID=10001
|
||||||
|
ARG APP_GID=10001
|
||||||
|
FROM ${PYTHON_IMAGE} AS base
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
@ -9,11 +12,40 @@ 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
|
||||||
|
|
||||||
|
ARG APP_UID
|
||||||
|
ARG APP_GID
|
||||||
|
|
||||||
|
RUN groupadd --gid "${APP_GID}" engin \
|
||||||
|
&& useradd --uid "${APP_UID}" \
|
||||||
|
--gid "${APP_GID}" \
|
||||||
|
--create-home \
|
||||||
|
--home-dir /home/engin \
|
||||||
|
--shell /usr/sbin/nologin \
|
||||||
|
engin
|
||||||
|
|
||||||
|
ENV STREAMLIT_BROWSER_GATHER_USAGE_STATS=false \
|
||||||
|
XDG_CACHE_HOME=/tmp/.cache \
|
||||||
|
MPLCONFIGDIR=/tmp/matplotlib
|
||||||
|
|
||||||
|
COPY app.py ./
|
||||||
|
COPY engin/ ./engin/
|
||||||
|
COPY assets/ ./assets/
|
||||||
|
COPY .streamlit/ ./.streamlit/
|
||||||
|
COPY artifacts/ ./artifacts/
|
||||||
|
COPY test.csv predictions.csv prediction_diagnostics.csv ./
|
||||||
|
|
||||||
|
USER ${APP_UID}:${APP_GID}
|
||||||
|
|
||||||
EXPOSE 8501
|
EXPOSE 8501
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8501/_stcore/health', timeout=3)"
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8501/_stcore/health', timeout=3)"
|
||||||
|
|
||||||
|
STOPSIGNAL SIGTERM
|
||||||
|
|
||||||
CMD ["streamlit", "run", "app.py", "--server.address=0.0.0.0", "--server.port=8501"]
|
CMD ["streamlit", "run", "app.py", "--server.address=0.0.0.0", "--server.port=8501"]
|
||||||
|
|||||||
55
README.md
55
README.md
@ -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
|
||||||
@ -27,19 +28,24 @@ Aplikacja otworzy się pod `http://localhost:8501`. Od razu uruchamia bezpieczne
|
|||||||
Alternatywnie przez Docker:
|
Alternatywnie przez Docker:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t engin-console .
|
docker build --target runtime -t engin-console .
|
||||||
docker run --rm -p 8501:8501 engin-console
|
docker run --rm -p 127.0.0.1:8501:8501 engin-console
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Zalecane uruchomienie produkcyjne korzysta z `compose.prod.yaml`: kontener działa
|
||||||
|
jako UID/GID `10001`, z systemem plików tylko do odczytu, bez Linux capabilities
|
||||||
|
oraz z limitami CPU, pamięci i procesów. Pełna procedura startu, aktualizacji i
|
||||||
|
rollbacku znajduje się w `docs/PRODUCTION_RUNBOOK.md`.
|
||||||
|
|
||||||
## Co zawiera produkt
|
## Co zawiera produkt
|
||||||
|
|
||||||
- mapa 8-, 12- i 16-cylindrowych silników z diagnozą każdego cylindra,
|
- przegląd silników 8-, 12- i 16-cylindrowych z diagnozą każdego cylindra,
|
||||||
- typ usterki: `ok`, `zakoksowany`, `lejacy`, `pompa`, `iglica` lub `unknown`,
|
- typ usterki: `ok`, `zakoksowany`, `lejacy`, `pompa`, `iglica` lub `unknown`,
|
||||||
- nasilenie: `male`, `srednie`, `duze`; dla `ok` i `unknown` zawsze `nie_dotyczy`,
|
- nasilenie: `male`, `srednie`, `duze`; dla `ok` i `unknown` zawsze `nie_dotyczy`,
|
||||||
- status silnika, najwyższe rzeczywiste severity i kolejka cylindrów do kontroli,
|
- status silnika, najwyższe rzeczywiste nasilenie i kolejka cylindrów do kontroli,
|
||||||
- heatmapa odchyleń całego silnika,
|
- mapa odchyleń całego silnika ze stałą, porównywalną skalą,
|
||||||
- porównanie widma cylindra z medianą pozostałych cylindrów tej samej jednostki,
|
- porównanie widma cylindra z medianą pozostałych cylindrów tej samej jednostki,
|
||||||
- trzy najbardziej anomalne pasma, niekalibrowany score modelu oraz rekomendowany następny krok,
|
- priorytet kontroli, trzy najbardziej anomalne pasma oraz krótkie uzasadnienie diagnozy,
|
||||||
- pobieranie `predictions.csv` i rozszerzonej diagnostyki,
|
- pobieranie `predictions.csv` i rozszerzonej diagnostyki,
|
||||||
- ścisła walidacja CSV i bezpieczne komunikaty błędów,
|
- ścisła walidacja CSV i bezpieczne komunikaty błędów,
|
||||||
- lokalne działanie na CPU, bez wysyłania danych do zewnętrznych usług.
|
- lokalne działanie na CPU, bez wysyłania danych do zewnętrznych usług.
|
||||||
@ -48,6 +54,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 +72,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 +113,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 +143,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,20 +164,12 @@ 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.
|
||||||
|
- Proces aplikacji działa jako użytkownik bez uprawnień roota; produkcyjny Compose
|
||||||
|
dodatkowo wymusza read-only root filesystem, usuwa capabilities i ustawia limity zasobów.
|
||||||
- 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.
|
||||||
- Zależności produkcyjne są przypięte w `requirements-lock.txt`.
|
- Zależności produkcyjne są przypięte w `requirements-lock.txt`.
|
||||||
- Aplikacja nie zmienia przesłanego pliku i nie wykonuje połączeń zewnętrznych.
|
- Aplikacja nie zmienia przesłanego pliku i nie wykonuje połączeń zewnętrznych.
|
||||||
|
|
||||||
## Materiały konkursowe
|
|
||||||
|
|
||||||
- [Scenariusz demo](docs/DEMO_SCENARIO.md)
|
|
||||||
- [Pytania i odpowiedzi dla jury](docs/JURY_QA.md)
|
|
||||||
- [Odpowiedź na przegląd i testy regresyjne](docs/REVIEW_RESPONSE.md)
|
|
||||||
- `predictions.csv` — finalny submission
|
|
||||||
- `prediction_diagnostics.csv` — rozszerzone dane do produktu
|
|
||||||
- `presentation/ENGIN_pitch_deck.pptx` — prezentacja konkursowa
|
|
||||||
|
|
||||||
Formuła konkursowa: `Raw Score = 0.75 × Macro F1(label) + 0.25 × Accuracy(severity dla uszkodzonych)`. Część ML daje maksymalnie 40 punktów; produkt, explainability, wydajność i prezentacja — 60 punktów.
|
|
||||||
|
|||||||
345
app.py
345
app.py
@ -7,18 +7,15 @@ import logging
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import streamlit as st
|
import streamlit as st
|
||||||
|
|
||||||
from engin.charts import cylinder_spectrum, deviation_chart, engine_heatmap
|
from engin.charts import cylinder_spectrum, deviation_chart, engine_heatmap
|
||||||
from engin.config import (
|
from engin.config import (
|
||||||
AppConfig,
|
|
||||||
LABEL_COLORS,
|
LABEL_COLORS,
|
||||||
LABEL_DISPLAY,
|
LABEL_DISPLAY,
|
||||||
LABEL_ICONS,
|
|
||||||
NOT_APPLICABLE,
|
|
||||||
SEVERITY_DISPLAY,
|
SEVERITY_DISPLAY,
|
||||||
|
AppConfig,
|
||||||
)
|
)
|
||||||
from engin.errors import UserFacingError
|
from engin.errors import UserFacingError
|
||||||
from engin.explainability import (
|
from engin.explainability import (
|
||||||
@ -32,9 +29,34 @@ from engin.model import PrecomputedPredictionModel, SklearnPredictionModel
|
|||||||
from engin.service import DiagnosisResult, DiagnosticService
|
from engin.service import DiagnosisResult, DiagnosticService
|
||||||
from engin.validation import SpectrumFrameValidator
|
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
|
||||||
|
VIEW_OVERVIEW = "Przegląd"
|
||||||
|
VIEW_DETAIL = "Szczegóły cylindra"
|
||||||
|
NO_COMPARISON = 0
|
||||||
|
PLOTLY_CONFIG = {"displayModeBar": False, "displaylogo": False}
|
||||||
|
DIAGNOSTIC_COLUMN_DISPLAY = {
|
||||||
|
"engine_id": "Silnik",
|
||||||
|
"cylinder": "Cylinder",
|
||||||
|
"label": "Diagnoza",
|
||||||
|
"severity": "Nasilenie",
|
||||||
|
"raw_model_label": "Surowa diagnoza modelu",
|
||||||
|
"decision_source": "Źródło decyzji",
|
||||||
|
"label_confidence": "Wynik diagnozy",
|
||||||
|
"raw_model_confidence": "Surowy wynik diagnozy",
|
||||||
|
"label_margin": "Margines decyzji",
|
||||||
|
"severity_confidence": "Wynik oceny nasilenia",
|
||||||
|
"anomaly_score_mean_abs_mv": "Średnie odchylenie bezwzględne [mV]",
|
||||||
|
"anomaly_ratio_to_engine_median": "Odchylenie względem mediany silnika",
|
||||||
|
"ood_absolute_threshold_mv": "Bezwzględny próg anomalii [mV]",
|
||||||
|
"ood_ratio_threshold": "Względny próg anomalii",
|
||||||
|
"top_anomalous_frequencies_khz": "Anomalne częstotliwości [kHz]",
|
||||||
|
"missing_spectral_cells": "Brakujące pomiary widma",
|
||||||
|
"model_version": "Wersja modelu",
|
||||||
|
"model_artifact_sha256": "Suma SHA-256 artefaktu",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@ -43,26 +65,30 @@ 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 as exc:
|
except Exception:
|
||||||
LOGGER.exception("Live model initialization failed; enabling demo fallback")
|
LOGGER.exception("Live model initialization failed; enabling demo fallback")
|
||||||
submission = pd.read_csv(BASE_DIR / "predictions.csv")
|
submission = pd.read_csv(BASE_DIR / "predictions.csv")
|
||||||
diagnostics = pd.read_csv(BASE_DIR / "prediction_diagnostics.csv")
|
diagnostics = pd.read_csv(BASE_DIR / "prediction_diagnostics.csv")
|
||||||
@ -72,9 +98,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="tryb-demonstracyjny",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -95,23 +122,36 @@ def _render_error(error: UserFacingError) -> None:
|
|||||||
st.caption(f"Kod błędu: `{error.code}`")
|
st.caption(f"Kod błędu: `{error.code}`")
|
||||||
|
|
||||||
|
|
||||||
def _format_confidence(value: float | int | None) -> str:
|
def _diagnostics_for_display(frame: pd.DataFrame) -> pd.DataFrame:
|
||||||
if value is None or pd.isna(value):
|
display = frame.copy()
|
||||||
return "—"
|
for column in ("label", "raw_model_label"):
|
||||||
return f"{100 * float(value):.0f}%"
|
if column in display:
|
||||||
|
display[column] = display[column].map(LABEL_DISPLAY).fillna(display[column])
|
||||||
|
if "severity" in display:
|
||||||
|
display["severity"] = (
|
||||||
|
display["severity"].map(SEVERITY_DISPLAY).fillna(display["severity"])
|
||||||
|
)
|
||||||
|
if "decision_source" in display:
|
||||||
|
display["decision_source"] = display["decision_source"].replace(
|
||||||
|
{
|
||||||
|
"classifier": "Klasyfikator spektralny",
|
||||||
|
"ood_override": "Reguła anomalii",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if "model_version" in display:
|
||||||
|
display["model_version"] = display["model_version"].replace(
|
||||||
|
{"demo-precomputed": "tryb demonstracyjny"}
|
||||||
|
)
|
||||||
|
return display.rename(columns=DIAGNOSTIC_COLUMN_DISPLAY)
|
||||||
|
|
||||||
|
|
||||||
def _render_header(live_inference: bool) -> None:
|
def _render_header() -> None:
|
||||||
mode = "LIVE INFERENCE" if live_inference else "DEMO FALLBACK"
|
|
||||||
st.markdown(
|
st.markdown(
|
||||||
f"""
|
"""
|
||||||
<div class="product-header">
|
<div class="product-header">
|
||||||
<div>
|
<div class="eyebrow">AESTEEL · DIAGNOSTYKA WTRYSKU DIESEL</div>
|
||||||
<div class="eyebrow">AESTEEL · DIESEL INJECTION DIAGNOSTICS</div>
|
<h1>Konsola diagnostyczna ENGIN</h1>
|
||||||
<h1>ENGIN Diagnostic Console</h1>
|
<p>Diagnoza cylindra, nasilenie i wyjaśnienie oparte na widmie.</p>
|
||||||
<p>Akustyczna diagnostyka każdego cylindra — typ usterki, nasilenie i uzasadnienie.</p>
|
|
||||||
</div>
|
|
||||||
<div class="runtime-badge">● {mode}<br><span>CPU · LEAKAGE-SAFE</span></div>
|
|
||||||
</div>
|
</div>
|
||||||
""",
|
""",
|
||||||
unsafe_allow_html=True,
|
unsafe_allow_html=True,
|
||||||
@ -125,67 +165,65 @@ def _render_summary(summary) -> None:
|
|||||||
<div><span>STATUS SILNIKA</span><strong>{html.escape(summary.status)}</strong></div>
|
<div><span>STATUS SILNIKA</span><strong>{html.escape(summary.status)}</strong></div>
|
||||||
<div><span>NAJWYŻSZE NASILENIE</span><strong>{html.escape(summary.highest_severity_display)}</strong></div>
|
<div><span>NAJWYŻSZE NASILENIE</span><strong>{html.escape(summary.highest_severity_display)}</strong></div>
|
||||||
<div><span>WYMAGA UWAGI</span><strong>{summary.attention} / {summary.cylinders}</strong></div>
|
<div><span>WYMAGA UWAGI</span><strong>{summary.attention} / {summary.cylinders}</strong></div>
|
||||||
<div><span>ŚR. SCORE MODELU</span><strong>{_format_confidence(summary.mean_confidence)}</strong></div>
|
<div><span>PIERWSZY DO KONTROLI</span><strong>C{summary.top_cylinder:02d}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
""",
|
""",
|
||||||
unsafe_allow_html=True,
|
unsafe_allow_html=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _select_cylinder(session_key: str, cylinder: int) -> None:
|
|
||||||
st.session_state[session_key] = cylinder
|
|
||||||
|
|
||||||
|
|
||||||
def _render_cylinder_grid(analysis, session_key: str) -> None:
|
|
||||||
st.markdown("### Mapa cylindrów")
|
|
||||||
columns = st.columns(4)
|
|
||||||
selected_cylinder = int(st.session_state[session_key])
|
|
||||||
for index, row in analysis.diagnostics.iterrows():
|
|
||||||
cylinder = int(row["cylinder"])
|
|
||||||
label = str(row["label"])
|
|
||||||
severity = str(row["severity"])
|
|
||||||
icon = LABEL_ICONS[label]
|
|
||||||
short_label = LABEL_DISPLAY[label]
|
|
||||||
if severity != NOT_APPLICABLE:
|
|
||||||
short_label += f" · {SEVERITY_DISPLAY[severity]}"
|
|
||||||
button_label = f"{icon} C{cylinder:02d}\n{short_label}"
|
|
||||||
with columns[index % 4]:
|
|
||||||
st.button(
|
|
||||||
button_label,
|
|
||||||
key=f"cylinder_{analysis.engine_id}_{cylinder}",
|
|
||||||
width="stretch",
|
|
||||||
type="primary" if cylinder == selected_cylinder else "secondary",
|
|
||||||
on_click=_select_cylinder,
|
|
||||||
args=(session_key, cylinder),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _render_engine_overview(analysis) -> None:
|
def _render_engine_overview(analysis) -> None:
|
||||||
left, right = st.columns([1.45, 1.0], gap="large")
|
left, right = st.columns(2, gap="large", vertical_alignment="top")
|
||||||
with left:
|
with left:
|
||||||
st.plotly_chart(engine_heatmap(analysis), width="stretch", key=f"heatmap_{analysis.engine_id}")
|
st.markdown(
|
||||||
|
'<div class="overview-panel-title">Mapa odchyleń</div>',
|
||||||
|
unsafe_allow_html=True,
|
||||||
|
)
|
||||||
|
st.plotly_chart(
|
||||||
|
engine_heatmap(analysis),
|
||||||
|
width="stretch",
|
||||||
|
key=f"heatmap_{analysis.engine_id}",
|
||||||
|
theme=None,
|
||||||
|
config=PLOTLY_CONFIG,
|
||||||
|
)
|
||||||
with right:
|
with right:
|
||||||
st.markdown("### Priorytet kontroli")
|
st.markdown(
|
||||||
|
'<div class="overview-panel-title">Priorytet kontroli</div>',
|
||||||
|
unsafe_allow_html=True,
|
||||||
|
)
|
||||||
ranking = rank_cylinders(analysis).head(6).copy()
|
ranking = rank_cylinders(analysis).head(6).copy()
|
||||||
|
ranking["Kolejność"] = range(1, len(ranking) + 1)
|
||||||
ranking["Cylinder"] = ranking["cylinder"].map(lambda value: f"C{int(value):02d}")
|
ranking["Cylinder"] = ranking["cylinder"].map(lambda value: f"C{int(value):02d}")
|
||||||
ranking["Diagnoza"] = ranking["label_display"]
|
ranking["Diagnoza"] = ranking["label_display"]
|
||||||
ranking["Nasilenie"] = ranking["severity_display"]
|
ranking["Nasilenie"] = ranking["severity_display"]
|
||||||
ranking["Score modelu"] = ranking["label_confidence"].map(_format_confidence)
|
ranking["Odchylenie [mV]"] = ranking["anomaly_score_mean_abs_mv"].round(1)
|
||||||
ranking["Priorytet"] = ranking["priority_display"]
|
|
||||||
st.dataframe(
|
st.dataframe(
|
||||||
ranking[["Cylinder", "Diagnoza", "Nasilenie", "Score modelu", "Priorytet"]],
|
ranking[
|
||||||
|
[
|
||||||
|
"Kolejność",
|
||||||
|
"Cylinder",
|
||||||
|
"Diagnoza",
|
||||||
|
"Nasilenie",
|
||||||
|
"Odchylenie [mV]",
|
||||||
|
]
|
||||||
|
],
|
||||||
hide_index=True,
|
hide_index=True,
|
||||||
width="stretch",
|
width="stretch",
|
||||||
height=315,
|
height=360,
|
||||||
)
|
)
|
||||||
st.caption("Kolejność: severity, odchylenie widma, następnie score modelu. Score nie jest skalibrowanym prawdopodobieństwem.")
|
|
||||||
|
|
||||||
|
|
||||||
def _render_cylinder_detail(analysis, cylinder: int) -> None:
|
def _render_cylinder_detail(
|
||||||
|
analysis,
|
||||||
|
cylinder: int,
|
||||||
|
comparison_cylinders: list[int],
|
||||||
|
) -> None:
|
||||||
explanation = explain_cylinder(analysis, cylinder)
|
explanation = explain_cylinder(analysis, cylinder)
|
||||||
row = analysis.diagnostics[analysis.diagnostics["cylinder"].eq(cylinder)].iloc[0]
|
|
||||||
color = LABEL_COLORS[explanation.label]
|
color = LABEL_COLORS[explanation.label]
|
||||||
severity_confidence = row.get("severity_confidence", np.nan)
|
priority = rank_cylinders(analysis).loc[
|
||||||
|
lambda frame: frame["cylinder"].eq(cylinder), "priority_display"
|
||||||
|
].iloc[0]
|
||||||
|
top_bands = ", ".join(f"{value} kHz" for value in explanation.top_frequencies)
|
||||||
st.markdown(
|
st.markdown(
|
||||||
f"""
|
f"""
|
||||||
<div class="diagnosis-card" style="--diagnosis-color:{color}">
|
<div class="diagnosis-card" style="--diagnosis-color:{color}">
|
||||||
@ -195,71 +233,135 @@ def _render_cylinder_detail(analysis, cylinder: int) -> None:
|
|||||||
<p>{html.escape(explanation.severity_display)}</p>
|
<p>{html.escape(explanation.severity_display)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="diagnosis-metrics">
|
<div class="diagnosis-metrics">
|
||||||
<div><span>Score label</span><strong>{_format_confidence(explanation.confidence)}</strong></div>
|
<div><span>Priorytet</span><strong>{html.escape(priority)}</strong></div>
|
||||||
<div><span>Score severity</span><strong>{_format_confidence(severity_confidence)}</strong></div>
|
|
||||||
<div><span>Średnie odchylenie</span><strong>{explanation.anomaly_score:.1f} mV</strong></div>
|
<div><span>Średnie odchylenie</span><strong>{explanation.anomaly_score:.1f} mV</strong></div>
|
||||||
|
<div><span>Główne pasma</span><strong>{html.escape(top_bands)}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
""",
|
""",
|
||||||
unsafe_allow_html=True,
|
unsafe_allow_html=True,
|
||||||
)
|
)
|
||||||
spectrum_col, deviation_col = st.columns([1.25, 1.0], gap="large")
|
st.markdown("#### Widma porównawcze")
|
||||||
with spectrum_col:
|
|
||||||
st.plotly_chart(
|
st.plotly_chart(
|
||||||
cylinder_spectrum(analysis, cylinder),
|
cylinder_spectrum(analysis, cylinder, comparison_cylinders),
|
||||||
width="stretch",
|
width="stretch",
|
||||||
key=f"spectrum_{analysis.engine_id}_{cylinder}",
|
key=(
|
||||||
|
f"spectrum_{analysis.engine_id}_"
|
||||||
|
+ "_".join(str(value) for value in comparison_cylinders)
|
||||||
|
),
|
||||||
|
theme=None,
|
||||||
|
config=PLOTLY_CONFIG,
|
||||||
)
|
)
|
||||||
with deviation_col:
|
|
||||||
|
st.markdown("#### Odchylenie cylindra głównego")
|
||||||
st.plotly_chart(
|
st.plotly_chart(
|
||||||
deviation_chart(analysis, cylinder),
|
deviation_chart(analysis, cylinder),
|
||||||
width="stretch",
|
width="stretch",
|
||||||
key=f"deviation_{analysis.engine_id}_{cylinder}",
|
key=f"deviation_{analysis.engine_id}_{cylinder}",
|
||||||
|
theme=None,
|
||||||
|
config=PLOTLY_CONFIG,
|
||||||
)
|
)
|
||||||
|
|
||||||
why, next_step = st.columns(2, gap="large")
|
st.markdown("#### Uzasadnienie diagnozy")
|
||||||
with why:
|
|
||||||
st.markdown("#### Dlaczego taka diagnoza?")
|
|
||||||
st.write(explanation.reason)
|
st.write(explanation.reason)
|
||||||
frequencies = " · ".join(f"{value} kHz" for value in explanation.top_frequencies)
|
|
||||||
st.markdown(f"**Najbardziej anomalne pasma:** `{frequencies}`")
|
|
||||||
with next_step:
|
def _store_selected_cylinder(state_key: str, widget_key: str) -> None:
|
||||||
st.markdown("#### Rekomendowany następny krok")
|
st.session_state[state_key] = int(st.session_state[widget_key])
|
||||||
st.write(explanation.recommendation)
|
|
||||||
source_label = "Reguła OOD" if explanation.decision_source == "ood_override" else "Klasyfikator spektralny"
|
|
||||||
st.caption(f"Źródło decyzji: {source_label}")
|
def _comparison_label(value: int) -> str:
|
||||||
st.caption("Score modelu służy do porównania predykcji; nie jest kalibrowanym prawdopodobieństwem awarii.")
|
if value == NO_COMPARISON:
|
||||||
|
return "Bez porównania"
|
||||||
|
return f"Cylinder {value:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def _render_cylinder_selectors(
|
||||||
|
analysis,
|
||||||
|
available: list[int],
|
||||||
|
selected_state_key: str,
|
||||||
|
) -> tuple[int, list[int]]:
|
||||||
|
selector_key = f"cylinder_selector_{analysis.engine_id}"
|
||||||
|
selected = int(st.session_state[selected_state_key])
|
||||||
|
if selector_key not in st.session_state or st.session_state[selector_key] not in available:
|
||||||
|
st.session_state[selector_key] = selected
|
||||||
|
|
||||||
|
with st.container(border=True):
|
||||||
|
st.markdown("### Wybór cylindra")
|
||||||
|
columns = st.columns([1.35, 1.0, 1.0, 1.0], gap="medium")
|
||||||
|
with columns[0]:
|
||||||
|
primary = int(
|
||||||
|
st.selectbox(
|
||||||
|
"Cylinder do analizy",
|
||||||
|
available,
|
||||||
|
format_func=lambda value: f"Cylinder {value:02d}",
|
||||||
|
key=selector_key,
|
||||||
|
on_change=_store_selected_cylinder,
|
||||||
|
args=(selected_state_key, selector_key),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
comparisons: list[int] = []
|
||||||
|
for slot, column in enumerate(columns[1:], start=1):
|
||||||
|
comparison_key = f"comparison_slot_{analysis.engine_id}_{slot}"
|
||||||
|
options = [
|
||||||
|
NO_COMPARISON,
|
||||||
|
*(
|
||||||
|
cylinder
|
||||||
|
for cylinder in available
|
||||||
|
if cylinder != primary and cylinder not in comparisons
|
||||||
|
),
|
||||||
|
]
|
||||||
|
if (
|
||||||
|
comparison_key not in st.session_state
|
||||||
|
or st.session_state[comparison_key] not in options
|
||||||
|
):
|
||||||
|
st.session_state[comparison_key] = NO_COMPARISON
|
||||||
|
with column:
|
||||||
|
comparison = int(
|
||||||
|
st.selectbox(
|
||||||
|
f"Porównanie {slot}",
|
||||||
|
options,
|
||||||
|
format_func=_comparison_label,
|
||||||
|
key=comparison_key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if comparison != NO_COMPARISON:
|
||||||
|
comparisons.append(comparison)
|
||||||
|
|
||||||
|
st.session_state[selected_state_key] = primary
|
||||||
|
return primary, comparisons
|
||||||
|
|
||||||
|
|
||||||
def _render_technical(result: DiagnosisResult) -> None:
|
def _render_technical(result: DiagnosisResult) -> None:
|
||||||
st.markdown("### Kontrola jakości i architektura")
|
st.markdown("### Walidacja i model")
|
||||||
c1, c2, c3, c4 = st.columns(4)
|
c1, c2, c3, c4 = st.columns(4)
|
||||||
c1.metric("Grouped Macro F1", "0.981")
|
c1.metric("Makro F1 (grupowe)", "0.981")
|
||||||
c2.metric("Severity accuracy", "0.930")
|
c2.metric("Trafność nasilenia", "0.930")
|
||||||
c3.metric("Walidacyjne ML", "33.65 / 40")
|
c3.metric("Punkty walidacyjne", "33.65 / 40")
|
||||||
c4.metric("Testy", "32/32")
|
c4.metric("Testy", "38 / 38")
|
||||||
st.markdown(
|
st.caption(
|
||||||
"""
|
"Walidacja grupowa według silników · średnia z 5 uruchomień · "
|
||||||
- **Zero leakage:** każdy fold zawiera kompletne, wcześniej niewidziane silniki.
|
"makro F1 przy 5% braków: 0.983 · wnioskowanie lokalne na CPU."
|
||||||
- **Odporność na braki:** wynik sprawdzony przy dokładnie 5% zamaskowanych komórek widma.
|
)
|
||||||
- **CPU-first:** Logistic Regression + Extra Trees, bez GPU i z deterministycznymi seedami.
|
st.markdown("#### Dane diagnostyczne")
|
||||||
- **Explainability:** każda diagnoza korzysta z referencji pozostałych cylindrów tego samego silnika.
|
st.dataframe(
|
||||||
"""
|
_diagnostics_for_display(result.diagnostics),
|
||||||
|
width="stretch",
|
||||||
|
hide_index=True,
|
||||||
)
|
)
|
||||||
with st.expander("Pokaż dane diagnostyczne"):
|
|
||||||
st.dataframe(result.diagnostics, width="stretch", hide_index=True)
|
|
||||||
|
|
||||||
|
|
||||||
def render_app(dependencies: AppDependencies | None = None) -> None:
|
def render_app(dependencies: AppDependencies | None = None) -> None:
|
||||||
st.set_page_config(
|
st.set_page_config(
|
||||||
page_title="ENGIN Diagnostic Console",
|
page_title="Konsola diagnostyczna ENGIN",
|
||||||
page_icon="⚙️",
|
page_icon="⚙️",
|
||||||
layout="wide",
|
layout="wide",
|
||||||
initial_sidebar_state="expanded",
|
initial_sidebar_state="expanded",
|
||||||
)
|
)
|
||||||
_load_css()
|
_load_css()
|
||||||
deps = dependencies or build_dependencies()
|
deps = dependencies or build_dependencies()
|
||||||
_render_header(deps.live_inference)
|
_render_header()
|
||||||
|
|
||||||
with st.sidebar:
|
with st.sidebar:
|
||||||
st.markdown("## Centrum diagnostyczne")
|
st.markdown("## Centrum diagnostyczne")
|
||||||
@ -271,7 +373,7 @@ def render_app(dependencies: AppDependencies | None = None) -> None:
|
|||||||
payload: bytes | None
|
payload: bytes | None
|
||||||
if source == "Dane demonstracyjne":
|
if source == "Dane demonstracyjne":
|
||||||
payload = deps.demo_payload
|
payload = deps.demo_payload
|
||||||
st.success("Załadowano bezpieczny zestaw demonstracyjny")
|
st.success("Dane demonstracyjne załadowane")
|
||||||
else:
|
else:
|
||||||
uploaded = st.file_uploader("Plik pomiarowy CSV", type=["csv"])
|
uploaded = st.file_uploader("Plik pomiarowy CSV", type=["csv"])
|
||||||
payload = uploaded.getvalue() if uploaded is not None else None
|
payload = uploaded.getvalue() if uploaded is not None else None
|
||||||
@ -303,46 +405,59 @@ def render_app(dependencies: AppDependencies | None = None) -> None:
|
|||||||
with st.sidebar:
|
with st.sidebar:
|
||||||
engine_id = st.selectbox("Aktywny silnik", result.engine_ids)
|
engine_id = st.selectbox("Aktywny silnik", result.engine_ids)
|
||||||
st.download_button(
|
st.download_button(
|
||||||
"Pobierz predictions.csv",
|
"Pobierz wyniki (CSV)",
|
||||||
data=result.submission.to_csv(index=False).encode("utf-8"),
|
data=result.submission.to_csv(index=False).encode("utf-8"),
|
||||||
file_name="predictions.csv",
|
file_name="predictions.csv",
|
||||||
mime="text/csv",
|
mime="text/csv",
|
||||||
width="stretch",
|
width="stretch",
|
||||||
)
|
)
|
||||||
st.download_button(
|
st.download_button(
|
||||||
"Pobierz diagnostykę",
|
"Pobierz dane diagnostyczne (CSV)",
|
||||||
data=result.diagnostics.to_csv(index=False).encode("utf-8"),
|
data=result.diagnostics.to_csv(index=False).encode("utf-8"),
|
||||||
file_name="prediction_diagnostics.csv",
|
file_name="prediction_diagnostics.csv",
|
||||||
mime="text/csv",
|
mime="text/csv",
|
||||||
width="stretch",
|
width="stretch",
|
||||||
)
|
)
|
||||||
st.divider()
|
|
||||||
st.caption("Model ENGIN v1 · 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)
|
||||||
_render_summary(summary)
|
_render_summary(summary)
|
||||||
|
|
||||||
session_key = f"selected_cylinder_{analysis.engine_id}"
|
session_key = f"active_cylinder_{analysis.engine_id}"
|
||||||
available = analysis.measurements["cylinder"].astype(int).tolist()
|
available = analysis.measurements["cylinder"].astype(int).tolist()
|
||||||
if session_key not in st.session_state or st.session_state[session_key] not in available:
|
if session_key not in st.session_state or st.session_state[session_key] not in available:
|
||||||
st.session_state[session_key] = summary.top_cylinder
|
st.session_state[session_key] = summary.top_cylinder
|
||||||
_render_cylinder_grid(analysis, session_key)
|
|
||||||
|
|
||||||
overview_tab, detail_tab, technical_tab = st.tabs(
|
view_key = f"active_view_{analysis.engine_id}"
|
||||||
["Przegląd silnika", "Szczegóły cylindra", "Walidacja i model"]
|
if view_key not in st.session_state:
|
||||||
|
st.session_state[view_key] = VIEW_OVERVIEW
|
||||||
|
view = st.segmented_control(
|
||||||
|
"Widok",
|
||||||
|
[VIEW_OVERVIEW, VIEW_DETAIL],
|
||||||
|
required=True,
|
||||||
|
key=view_key,
|
||||||
|
label_visibility="collapsed",
|
||||||
|
width="stretch",
|
||||||
)
|
)
|
||||||
with overview_tab:
|
|
||||||
|
if view == VIEW_OVERVIEW:
|
||||||
_render_engine_overview(analysis)
|
_render_engine_overview(analysis)
|
||||||
with detail_tab:
|
elif view == VIEW_DETAIL:
|
||||||
selected_from_box = st.selectbox(
|
selected_from_box, additional_cylinders = _render_cylinder_selectors(
|
||||||
"Cylinder do analizy",
|
analysis,
|
||||||
available,
|
available,
|
||||||
format_func=lambda value: f"Cylinder {value:02d}",
|
session_key,
|
||||||
key=session_key,
|
|
||||||
)
|
)
|
||||||
_render_cylinder_detail(analysis, int(selected_from_box))
|
comparison_cylinders = [
|
||||||
with technical_tab:
|
int(selected_from_box),
|
||||||
|
*(int(value) for value in additional_cylinders),
|
||||||
|
]
|
||||||
|
_render_cylinder_detail(
|
||||||
|
analysis,
|
||||||
|
int(selected_from_box),
|
||||||
|
comparison_cylinders,
|
||||||
|
)
|
||||||
|
|
||||||
|
with st.expander("Informacje techniczne", expanded=False):
|
||||||
_render_technical(result)
|
_render_technical(result)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
49
artifacts/engin-2026.08.25-1/manifest.json
Normal file
49
artifacts/engin-2026.08.25-1/manifest.json
Normal 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"
|
||||||
|
}
|
||||||
BIN
artifacts/engin-2026.08.25-1/model.pkl
Normal file
BIN
artifacts/engin-2026.08.25-1/model.pkl
Normal file
Binary file not shown.
129
assets/app.css
129
assets/app.css
@ -9,6 +9,7 @@
|
|||||||
--green: #38d996;
|
--green: #38d996;
|
||||||
--orange: #f5a524;
|
--orange: #f5a524;
|
||||||
--red: #ff6b57;
|
--red: #ff6b57;
|
||||||
|
--radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-testid="stAppViewContainer"] {
|
[data-testid="stAppViewContainer"] {
|
||||||
@ -22,86 +23,142 @@
|
|||||||
[data-testid="stSidebar"] { background: #0b171d; border-right: 1px solid var(--line); }
|
[data-testid="stSidebar"] { background: #0b171d; border-right: 1px solid var(--line); }
|
||||||
[data-testid="stMainBlockContainer"] { padding-top: 2.1rem; max-width: 1500px; }
|
[data-testid="stMainBlockContainer"] { padding-top: 2.1rem; max-width: 1500px; }
|
||||||
|
|
||||||
|
/* Streamlit sizes segmented-control items from their labels. The main view
|
||||||
|
switch is intentionally a balanced two-column control. */
|
||||||
|
[data-testid="stMainBlockContainer"] [data-testid="stButtonGroup"] [role="radiogroup"] {
|
||||||
|
display: grid !important;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
[data-testid="stMainBlockContainer"] [data-testid="stButtonGroup"] [role="radiogroup"] > button {
|
||||||
|
width: 100% !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
}
|
||||||
|
|
||||||
.product-header {
|
.product-header {
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 2rem;
|
|
||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
padding-bottom: 1.25rem;
|
padding-bottom: 1.25rem;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-header h1 { margin: .2rem 0 .25rem; color: #f3f8fa; font-size: 2rem; letter-spacing: -.035em; }
|
.product-header h1 { margin: .2rem 0 .25rem; color: #f3f8fa; font-size: 2rem; line-height: 1.12; letter-spacing: -.035em; }
|
||||||
.product-header p { margin: 0; color: var(--muted); }
|
.product-header p { margin: 0; color: var(--muted); }
|
||||||
.eyebrow { color: var(--cyan); font-size: .7rem; font-weight: 800; letter-spacing: .17em; }
|
.eyebrow { color: var(--cyan); font-size: .7rem; font-weight: 800; letter-spacing: .17em; }
|
||||||
.runtime-badge {
|
|
||||||
min-width: 170px;
|
|
||||||
padding: .7rem .9rem;
|
|
||||||
border: 1px solid rgba(56, 217, 150, .32);
|
|
||||||
background: rgba(56, 217, 150, .07);
|
|
||||||
color: var(--green);
|
|
||||||
font: 700 .72rem/1.35 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
||||||
letter-spacing: .06em;
|
|
||||||
}
|
|
||||||
.runtime-badge span { color: var(--muted); font-size: .64rem; }
|
|
||||||
|
|
||||||
.status-strip {
|
.status-strip {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-left: 4px solid var(--green);
|
border-left: 4px solid var(--green);
|
||||||
|
border-radius: var(--radius);
|
||||||
background: linear-gradient(90deg, rgba(56, 217, 150, .07), rgba(16, 27, 34, .92));
|
background: linear-gradient(90deg, rgba(56, 217, 150, .07), rgba(16, 27, 34, .92));
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.status-strip > div { padding: 1rem 1.2rem; border-right: 1px solid var(--line); }
|
.status-strip > div { padding: 1rem 1.2rem; border-right: 1px solid var(--line); text-align: center; }
|
||||||
.status-strip > div:last-child { border-right: 0; }
|
.status-strip > div:last-child { border-right: 0; }
|
||||||
.status-strip span, .diagnosis-metrics span { display: block; color: var(--muted); font-size: .66rem; font-weight: 800; letter-spacing: .11em; }
|
.status-strip span, .diagnosis-metrics span { display: block; color: var(--muted); font-size: .66rem; font-weight: 800; letter-spacing: .11em; }
|
||||||
.status-strip strong { display: block; margin-top: .2rem; font-size: 1.28rem; color: var(--text); }
|
.status-strip strong { display: block; margin-top: .2rem; font-size: 1.28rem; color: var(--text); }
|
||||||
.status-warning { border-left-color: var(--orange); background: linear-gradient(90deg, rgba(245, 165, 36, .08), rgba(16, 27, 34, .92)); }
|
.status-warning { border-left-color: var(--orange); background: linear-gradient(90deg, rgba(245, 165, 36, .08), rgba(16, 27, 34, .92)); }
|
||||||
.status-critical { border-left-color: var(--red); background: linear-gradient(90deg, rgba(255, 107, 87, .09), rgba(16, 27, 34, .92)); }
|
.status-critical { border-left-color: var(--red); background: linear-gradient(90deg, rgba(255, 107, 87, .09), rgba(16, 27, 34, .92)); }
|
||||||
.status-unknown { border-left-color: #d56cff; }
|
.status-unknown {
|
||||||
|
border-left-color: #d56cff;
|
||||||
|
background: linear-gradient(90deg, rgba(213, 108, 255, .08), rgba(16, 27, 34, .92));
|
||||||
|
}
|
||||||
|
|
||||||
.diagnosis-card {
|
.diagnosis-card {
|
||||||
--diagnosis-color: var(--cyan);
|
--diagnosis-color: var(--cyan);
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, .8fr) minmax(0, 1.6fr);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
padding: 1rem 1.25rem;
|
padding: 1rem 1.25rem;
|
||||||
margin: .5rem 0 1rem;
|
margin: .5rem 0 1rem;
|
||||||
background: linear-gradient(90deg, color-mix(in srgb, var(--diagnosis-color) 12%, transparent), var(--panel));
|
background: linear-gradient(90deg, color-mix(in srgb, var(--diagnosis-color) 12%, transparent), var(--panel));
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-left: 4px solid var(--diagnosis-color);
|
border-left: 4px solid var(--diagnosis-color);
|
||||||
|
border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
.diagnosis-card h2 { margin: .08rem 0; color: var(--text); }
|
.diagnosis-card h2 { margin: .08rem 0; color: var(--text); }
|
||||||
.diagnosis-card p { margin: 0; color: var(--muted); }
|
.diagnosis-card p { margin: 0; color: var(--muted); }
|
||||||
.diagnosis-kicker { color: var(--diagnosis-color); font: 800 .68rem ui-monospace, monospace; letter-spacing: .12em; }
|
.diagnosis-kicker { color: var(--diagnosis-color); font: 800 .68rem ui-monospace, monospace; letter-spacing: .12em; }
|
||||||
.diagnosis-metrics { display: flex; gap: 2rem; }
|
.diagnosis-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(110px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
.diagnosis-metrics strong { display: block; margin-top: .2rem; color: var(--text); font-size: 1.05rem; }
|
.diagnosis-metrics strong { display: block; margin-top: .2rem; color: var(--text); font-size: 1.05rem; }
|
||||||
|
|
||||||
[data-testid="stButton"] button {
|
.overview-panel-title {
|
||||||
min-height: 4.2rem;
|
display: flex;
|
||||||
white-space: pre-line;
|
min-height: 2.75rem;
|
||||||
border-radius: 3px;
|
align-items: center;
|
||||||
border-color: var(--line);
|
justify-content: center;
|
||||||
background: var(--panel);
|
color: var(--text);
|
||||||
font-weight: 650;
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
[data-testid="stButton"] button:hover { border-color: var(--cyan); color: #fff; }
|
|
||||||
[data-testid="stButton"] button[kind="primary"] { border-color: var(--cyan); background: rgba(40, 183, 217, .13); }
|
|
||||||
|
|
||||||
[data-testid="stMetric"] { padding: .7rem; border: 1px solid var(--line); background: var(--panel); }
|
[data-testid="stVerticalBlockBorderWrapper"] {
|
||||||
[data-testid="stDataFrame"], [data-testid="stPlotlyChart"] { border: 1px solid var(--line); }
|
border-color: rgba(40, 183, 217, .32) !important;
|
||||||
|
border-left: 4px solid var(--cyan) !important;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: linear-gradient(90deg, rgba(40, 183, 217, .06), rgba(16, 27, 34, .72));
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
[data-testid="stMetric"] {
|
||||||
.product-header, .diagnosis-card { flex-direction: column; }
|
min-height: 5.2rem;
|
||||||
.runtime-badge { width: 100%; }
|
padding: .75rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
[data-testid="stDataFrame"], [data-testid="stPlotlyChart"] {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--panel);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
[data-testid="stDownloadButton"] button,
|
||||||
|
[data-testid="stFileUploaderDropzone"] {
|
||||||
|
border-color: var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
[data-testid="stDownloadButton"] button:hover { border-color: var(--cyan); }
|
||||||
|
[data-testid="stExpander"] details {
|
||||||
|
border-color: var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: rgba(16, 27, 34, .58);
|
||||||
|
}
|
||||||
|
[data-baseweb="select"] > div { border-radius: var(--radius); }
|
||||||
|
|
||||||
|
/* Streamlit 1.62 has no interface locale setting, so localize its uploader chrome. */
|
||||||
|
[data-testid="stFileUploaderDropzone"] button [data-testid="stMarkdownContainer"] { display: none; }
|
||||||
|
[data-testid="stFileUploaderDropzone"] button::after { content: "Wybierz plik"; }
|
||||||
|
[data-testid="stFileUploaderDropzoneInstructions"] > div { display: none; }
|
||||||
|
[data-testid="stFileUploaderDropzoneInstructions"]::after {
|
||||||
|
content: "Maks. 10 MB na plik · CSV";
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: .875rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
[data-testid="stFileUploaderDropzone"] > div:not([data-testid]) > span { font-size: 0; }
|
||||||
|
[data-testid="stFileUploaderDropzone"] > div:not([data-testid]) > span::after {
|
||||||
|
content: "Upuść plik tutaj";
|
||||||
|
font-size: .875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
.diagnosis-card { grid-template-columns: 1fr; }
|
||||||
.status-strip { grid-template-columns: repeat(2, 1fr); }
|
.status-strip { grid-template-columns: repeat(2, 1fr); }
|
||||||
.diagnosis-metrics { width: 100%; flex-wrap: wrap; gap: 1rem 1.6rem; }
|
.diagnosis-metrics { width: 100%; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
|
.product-header h1 { font-size: 1.65rem; }
|
||||||
.status-strip { grid-template-columns: 1fr; }
|
.status-strip { grid-template-columns: 1fr; }
|
||||||
.status-strip > div { border-right: 0; border-bottom: 1px solid var(--line); }
|
.status-strip > div { border-right: 0; border-bottom: 1px solid var(--line); }
|
||||||
|
.status-strip > div:last-child { border-bottom: 0; }
|
||||||
|
.diagnosis-metrics { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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")
|
||||||
|
|
||||||
|
|||||||
31
compose.prod.yaml
Normal file
31
compose.prod.yaml
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
services:
|
||||||
|
engin:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: runtime
|
||||||
|
image: "engin-console:${ENGIN_IMAGE_TAG:-local}"
|
||||||
|
restart: unless-stopped
|
||||||
|
init: true
|
||||||
|
user: "10001:10001"
|
||||||
|
ports:
|
||||||
|
- "${ENGIN_BIND_ADDRESS:-127.0.0.1}:${ENGIN_PORT:-8501}:8501"
|
||||||
|
environment:
|
||||||
|
STREAMLIT_BROWSER_GATHER_USAGE_STATS: "false"
|
||||||
|
XDG_CACHE_HOME: /tmp/.cache
|
||||||
|
MPLCONFIGDIR: /tmp/matplotlib
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- "/tmp:rw,noexec,nosuid,size=64m,mode=1777"
|
||||||
|
security_opt:
|
||||||
|
- "no-new-privileges:true"
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
pids_limit: 256
|
||||||
|
mem_limit: 1g
|
||||||
|
cpus: 2.0
|
||||||
|
stop_grace_period: 30s
|
||||||
|
logging:
|
||||||
|
driver: local
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
@ -2,43 +2,44 @@
|
|||||||
|
|
||||||
## 0:00–0:35 — problem
|
## 0:00–0:35 — problem
|
||||||
|
|
||||||
„Jeden przemysłowy silnik ma 8, 12 lub 16 cylindrów. Mechanik dostaje 21 punktów widma dla każdego cylindra, ale potrzebuje decyzji: który cylinder sprawdzić, jakiej usterki szukać i jak pilna jest interwencja. ENGIN zamienia te pomiary w kolejkę konkretnych działań.”
|
„Jeden przemysłowy silnik ma 8, 12 lub 16 cylindrów. Mechanik dostaje 21 punktów widma dla każdego cylindra, ale potrzebuje szybkiej odpowiedzi: który cylinder odstaje, jaki typ usterki wskazuje sygnał i jak duże jest nasilenie. ENGIN porządkuje cylindry do kontroli i pokazuje dowód w widmie.”
|
||||||
|
|
||||||
## 0:35–1:15 — ekran silnika
|
## 0:35–1:15 — ekran silnika
|
||||||
|
|
||||||
1. Uruchom `streamlit run app.py` i pozostaw „Dane demonstracyjne”.
|
1. Uruchom `streamlit run app.py` i pozostaw „Dane demonstracyjne”.
|
||||||
2. Wskaż status silnika, najwyższe severity i liczbę cylindrów wymagających uwagi.
|
2. Wskaż status silnika, najwyższe nasilenie i liczbę cylindrów wymagających uwagi.
|
||||||
3. Pokaż mapę cylindrów oraz ranking kontroli.
|
3. Pokaż mapę odchyleń: diagnoza jest zapisana przy każdym cylindrze, a stała skala kolorów pozwala porównywać silniki.
|
||||||
|
|
||||||
Komunikat: „Nie zmuszamy mechanika do czytania 21 wykresów. Najpierw widzi najważniejszy cylinder i pilność.”
|
Komunikat: „Nie zmuszamy mechanika do czytania 21 wykresów. Najpierw widzi najważniejszy cylinder i pilność.”
|
||||||
|
|
||||||
## 1:15–2:20 — explainability
|
## 1:15–2:20 — uzasadnienie diagnozy
|
||||||
|
|
||||||
1. Otwórz „Szczegóły cylindra”.
|
1. Otwórz „Szczegóły cylindra”.
|
||||||
2. Wybierz najwyżej sklasyfikowany cylinder.
|
2. Wybierz najwyżej sklasyfikowany cylinder.
|
||||||
3. Porównaj jego widmo z medianą pozostałych cylindrów tego samego silnika.
|
3. Opcjonalnie dodaj drugi cylinder do porównania.
|
||||||
4. Wskaż zaznaczone pasma, score modelu, severity i rekomendowany następny krok.
|
4. Porównaj widmo z medianą pozostałych cylindrów tego samego silnika.
|
||||||
|
5. Wskaż priorytet, zaznaczone pasma, nasilenie i krótkie uzasadnienie diagnozy.
|
||||||
|
|
||||||
Komunikat: „Referencją jest konkretny silnik, nie abstrakcyjna średnia całej floty. Dzięki temu wynik jest łatwiejszy do zweryfikowania przez człowieka.”
|
Komunikat: „Referencją jest konkretny silnik, nie abstrakcyjna średnia całej floty. Dzięki temu wynik jest łatwiejszy do zweryfikowania przez człowieka.”
|
||||||
|
|
||||||
## 2:20–3:05 — odporność
|
## 2:20–3:05 — odporność
|
||||||
|
|
||||||
1. Przejdź do „Walidacja i model”.
|
1. Rozwiń „Informacje techniczne”.
|
||||||
2. Pokaż grouped Macro F1 0.981, severity 0.930 i wynik przy 5% braków.
|
2. Pokaż grupowe makro F1 0.981, trafność nasilenia 0.930 i wynik przy 5% braków.
|
||||||
3. Powiedz, że cały silnik jest zawsze w jednym foldzie i że inference działa na CPU.
|
3. Powiedz, że cały silnik jest zawsze w jednym zbiorze walidacyjnym, a model działa na CPU.
|
||||||
|
|
||||||
Komunikat: „Wynik nie powstał przez przeciek między cylindrami tego samego silnika. Pipeline jest też testowany na brakach odpowiadających realnym danym warsztatowym.”
|
Komunikat: „Wynik nie powstał przez przeciek między cylindrami tego samego silnika. Proces jest też testowany na brakach odpowiadających realnym danym warsztatowym.”
|
||||||
|
|
||||||
## 3:05–3:40 — własny plik i wartość
|
## 3:05–3:40 — własny plik i wartość
|
||||||
|
|
||||||
1. Pokaż opcję „Wgraj plik CSV”, ale nie przełączaj jej, jeśli czas jest napięty.
|
1. Pokaż opcję „Wgraj plik CSV”, ale nie przełączaj jej, jeśli czas jest napięty.
|
||||||
2. Pokaż przyciski pobrania submission oraz pełnej diagnostyki.
|
2. Pokaż przyciski pobrania wyników oraz pełnej diagnostyki.
|
||||||
|
|
||||||
Zamknięcie: „ENGIN daje trzy rzeczy naraz: konkursowo skuteczny model, weryfikowalne uzasadnienie i prostą decyzję serwisową. Działa lokalnie na CPU i jest gotowy na podłączenie do warsztatowego procesu.”
|
Zamknięcie: „ENGIN daje trzy rzeczy naraz: skuteczną klasyfikację, weryfikowalne uzasadnienie i jasną kolejność kontroli cylindrów. Działa lokalnie na CPU i jest gotowy na podłączenie do warsztatowego procesu.”
|
||||||
|
|
||||||
## Plan awaryjny
|
## Plan awaryjny
|
||||||
|
|
||||||
- Jeśli nie ma internetu: aplikacja nie potrzebuje internetu.
|
- Jeśli nie ma internetu: aplikacja nie potrzebuje internetu.
|
||||||
- Jeśli model nie wystartuje: automatycznie działa fallback na zapisanych predykcjach demo.
|
- Jeśli model nie wystartuje: automatycznie działa tryb awaryjny na zapisanych predykcjach demonstracyjnych.
|
||||||
- Jeśli brakuje czasu: pokaż ekran główny, jeden cylinder i metryki — około 90 sekund.
|
- Jeśli brakuje czasu: pokaż ekran główny, jeden cylinder i metryki — około 90 sekund.
|
||||||
- Przed wystąpieniem uruchom `python -m unittest discover -v` i zachowaj terminal z wynikiem 32/32.
|
- Przed wystąpieniem uruchom `python -m unittest discover -v` i zachowaj terminal z wynikiem 38/38.
|
||||||
|
|||||||
@ -28,7 +28,7 @@ Nie. Status jest deterministyczną regułą biznesową opartą bezpośrednio na
|
|||||||
|
|
||||||
## Czy score modelu jest prawdopodobieństwem awarii?
|
## Czy score modelu jest prawdopodobieństwem awarii?
|
||||||
|
|
||||||
Nie. To niekalibrowany wynik `predict_proba`, używany wyłącznie do względnego porównania decyzji klasyfikatora. Interfejs nazywa go „score”, nie „pewnością”. Dla `unknown` utworzonego przez regułę OOD pozostaje pusty zamiast sztucznie generować wartość 50–99%.
|
Nie. To niekalibrowany wynik `predict_proba`, używany wyłącznie do diagnostyki technicznej. Główny widok mechanika go nie pokazuje; zamiast tego prezentuje diagnozę, nasilenie, priorytet oraz dowód w widmie i porównanie z pozostałymi cylindrami. Dla `unknown` utworzonego przez regułę OOD wartość pozostaje pusta.
|
||||||
|
|
||||||
## Co robi aplikacja przy błędnym pliku?
|
## Co robi aplikacja przy błędnym pliku?
|
||||||
|
|
||||||
|
|||||||
103
docs/PRODUCTION_RUNBOOK.md
Normal file
103
docs/PRODUCTION_RUNBOOK.md
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
# ENGIN — production runbook
|
||||||
|
|
||||||
|
## Kontrakt wdrożenia
|
||||||
|
|
||||||
|
- Docker Engine i Docker Compose v2.
|
||||||
|
- Aplikacja działa jako UID/GID `10001:10001`.
|
||||||
|
- Port jest domyślnie dostępny tylko na `127.0.0.1:8501`.
|
||||||
|
- Root filesystem kontenera jest tylko do odczytu; zapisywalny jest wyłącznie
|
||||||
|
tymczasowy `/tmp` o rozmiarze 64 MiB.
|
||||||
|
- Kontener nie ma Linux capabilities i nie może uzyskać nowych uprawnień.
|
||||||
|
- Limity: 2 CPU, 1 GiB RAM i 256 procesów.
|
||||||
|
- Przesłane CSV nie są zapisywane na dysku ani w wolumenie.
|
||||||
|
|
||||||
|
## Pierwszy start
|
||||||
|
|
||||||
|
Wykonuj polecenia z katalogu repozytorium na czystym, zatwierdzonym commicie:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export ENGIN_IMAGE_TAG="$(git rev-parse --short HEAD)"
|
||||||
|
|
||||||
|
docker compose -f compose.prod.yaml config --quiet
|
||||||
|
docker compose -f compose.prod.yaml build
|
||||||
|
docker compose -f compose.prod.yaml up -d --no-build
|
||||||
|
docker compose -f compose.prod.yaml ps
|
||||||
|
|
||||||
|
curl --fail http://127.0.0.1:8501/_stcore/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Oczekiwana odpowiedź healthchecku: `ok`.
|
||||||
|
|
||||||
|
## Kontrola ograniczeń
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CONTAINER_ID="$(docker compose -f compose.prod.yaml ps -q engin)"
|
||||||
|
|
||||||
|
docker inspect --format \
|
||||||
|
'user={{.Config.User}} readonly={{.HostConfig.ReadonlyRootfs}} pids={{.HostConfig.PidsLimit}} memory={{.HostConfig.Memory}} nanocpus={{.HostConfig.NanoCpus}}' \
|
||||||
|
"$CONTAINER_ID"
|
||||||
|
```
|
||||||
|
|
||||||
|
Oczekiwane wartości:
|
||||||
|
|
||||||
|
```text
|
||||||
|
user=10001:10001 readonly=true pids=256 memory=1073741824 nanocpus=2000000000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Logi i diagnostyka
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f compose.prod.yaml logs --tail 200 engin
|
||||||
|
docker compose -f compose.prod.yaml ps
|
||||||
|
```
|
||||||
|
|
||||||
|
Logi są rotowane przez sterownik `local`: maksymalnie trzy pliki po 10 MiB.
|
||||||
|
|
||||||
|
## Aktualizacja
|
||||||
|
|
||||||
|
Najpierw zachowaj SHA aktualnie działającego obrazu, następnie zbuduj nową,
|
||||||
|
jednoznacznie otagowaną wersję:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f compose.prod.yaml ps
|
||||||
|
git pull --ff-only
|
||||||
|
|
||||||
|
export ENGIN_IMAGE_TAG="$(git rev-parse --short HEAD)"
|
||||||
|
docker compose -f compose.prod.yaml build
|
||||||
|
docker compose -f compose.prod.yaml up -d --no-build
|
||||||
|
curl --fail http://127.0.0.1:8501/_stcore/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Nie usuwaj poprzedniego obrazu przed zakończeniem smoke testu.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Jeżeli smoke test nowej wersji nie przejdzie, uruchom poprzedni lokalny tag bez
|
||||||
|
ponownego budowania:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ENGIN_IMAGE_TAG=<poprzedni_git_sha> \
|
||||||
|
docker compose -f compose.prod.yaml up -d --no-build
|
||||||
|
|
||||||
|
curl --fail http://127.0.0.1:8501/_stcore/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Jeżeli obrazu o wskazanym tagu już nie ma, przełącz repo na zatwierdzony commit,
|
||||||
|
odbuduj obraz i ponownie wykonaj smoke test. Nie używaj niezaufanego artefaktu
|
||||||
|
modelu jako skrótu do rollbacku.
|
||||||
|
|
||||||
|
## Udostępnienie poza hostem
|
||||||
|
|
||||||
|
Domyślne wiązanie `127.0.0.1` jest celowe. Dostęp sieciowy włączaj dopiero za
|
||||||
|
reverse proxy z TLS i regułami firewalla. W takim środowisku można ustawić:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ENGIN_BIND_ADDRESS=0.0.0.0 \
|
||||||
|
docker compose -f compose.prod.yaml up -d --no-build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Zatrzymanie
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f compose.prod.yaml down
|
||||||
|
```
|
||||||
@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
| Uwaga z przeglądu | Zmiana | Dowód regresji |
|
| Uwaga z przeglądu | Zmiana | Dowód regresji |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Mapa cylindrów i dropdown nadpisywały sobie stan | oba widgety korzystają z jednego klucza session state; kafelki ustawiają go callbackiem | `test_cylinder_grid_and_detail_selector_share_state` |
|
| Wybór cylindra niepotrzebnie obciążał przegląd silnika | wybór jest dostępny wyłącznie w widoku szczegółów; przegląd pokazuje tylko stan i priorytety | `test_cylinder_selector_exists_only_in_detail_view` |
|
||||||
|
| Selektor i karta mogły po pierwszym wejściu wskazywać różne cylindry | trwały stan wyboru i stan widżetu mają osobne klucze; test porównuje wartość selektora z nagłówkiem karty | `test_cylinder_selector_exists_only_in_detail_view` |
|
||||||
|
| Wielokrotny wybór pokazywał angielskie i sprzeczne komunikaty | zastąpiono go trzema opcjonalnymi, polskimi polami porównawczymi bez możliwości duplikatów | `test_cylinder_selector_exists_only_in_detail_view` |
|
||||||
|
| Automatyczna skala mapy wyolbrzymiała różnice w zdrowych silnikach | mapa ma stałą skalę ±20 mV i opis diagnozy przy każdym cylindrze | `test_all_chart_factories_return_populated_figures` |
|
||||||
| Status `WYMAGA WERYFIKACJI` był nieosiągalny | status zależy bezpośrednio od label/severity; `unknown` bez nazwanej usterki ma dedykowany status | `test_unknown_only_engine_requires_verification` |
|
| Status `WYMAGA WERYFIKACJI` był nieosiągalny | status zależy bezpośrednio od label/severity; `unknown` bez nazwanej usterki ma dedykowany status | `test_unknown_only_engine_requires_verification` |
|
||||||
| Engine Health 0–100 był arbitralny | liczba została usunięta; UI pokazuje najwyższe rzeczywiste severity i porządkowy triage | testy explainability + smoke UI |
|
| Engine Health 0–100 był arbitralny | liczba została usunięta; UI pokazuje najwyższe rzeczywiste severity i porządkowy triage | testy explainability + smoke UI |
|
||||||
| OOD otrzymywał sztuczny confidence 50–99% | score OOD jest `NaN`; UI nazywa pozostałe wartości score modelu i wyjaśnia brak kalibracji | `test_diagnostics_are_complete_for_the_application` |
|
| OOD otrzymywał sztuczny confidence 50–99% | score OOD jest `NaN`; UI nazywa pozostałe wartości score modelu i wyjaśnia brak kalibracji | `test_diagnostics_are_complete_for_the_application` |
|
||||||
@ -15,4 +18,4 @@ Po zmianie OOD ponownie wykonano pięć seedów grouped CV dla clean i masked 5%
|
|||||||
- clean: Macro F1 `0.9811`, severity `0.9298`, Raw Score `0.9683`,
|
- clean: Macro F1 `0.9811`, severity `0.9298`, Raw Score `0.9683`,
|
||||||
- masked 5%: Macro F1 `0.9829`, severity `0.9333`, Raw Score `0.9705`.
|
- masked 5%: Macro F1 `0.9829`, severity `0.9333`, Raw Score `0.9705`.
|
||||||
|
|
||||||
Pełny pakiet po iteracji: `32/32` testów oraz poprawny healthcheck Streamlit `ok`.
|
Pełny pakiet po iteracji: `38/38` testów oraz poprawny test stanu Streamlit `ok`.
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
"""Core application package for the ENGIN diagnostic console."""
|
"""Core application package for the ENGIN diagnostic console."""
|
||||||
|
|
||||||
from .service import DiagnosticService, DiagnosisResult
|
from .service import DiagnosisResult, DiagnosticService
|
||||||
|
|
||||||
__all__ = ["DiagnosticService", "DiagnosisResult"]
|
__all__ = ["DiagnosticService", "DiagnosisResult"]
|
||||||
|
|||||||
257
engin/artifact.py
Normal file
257
engin/artifact.py
Normal 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",
|
||||||
|
]
|
||||||
124
engin/charts.py
124
engin/charts.py
@ -2,66 +2,110 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import plotly.graph_objects as go
|
import plotly.graph_objects as go
|
||||||
|
|
||||||
from .config import FREQ_COLS, LABEL_COLORS
|
from .config import FREQ_COLS, LABEL_COLORS, LABEL_DISPLAY
|
||||||
from .explainability import EngineAnalysis
|
from .explainability import EngineAnalysis
|
||||||
|
|
||||||
|
|
||||||
PLOT_BG = "#101820"
|
PLOT_BG = "#101820"
|
||||||
GRID = "rgba(148, 163, 184, 0.14)"
|
GRID = "rgba(148, 163, 184, 0.14)"
|
||||||
TEXT = "#dce8ee"
|
TEXT = "#dce8ee"
|
||||||
MUTED = "#8fa6b2"
|
MUTED = "#8fa6b2"
|
||||||
|
COMPARISON_COLORS = ("#28b7d9", "#f5a524", "#38d996", "#d56cff", "#2f78ed")
|
||||||
|
HEATMAP_DEVIATION_LIMIT_MV = 20.0
|
||||||
|
|
||||||
|
|
||||||
def _base_layout(fig: go.Figure, *, height: int) -> go.Figure:
|
def _base_layout(
|
||||||
|
fig: go.Figure,
|
||||||
|
*,
|
||||||
|
height: int,
|
||||||
|
margin_top: int = 42,
|
||||||
|
margin_bottom: int = 28,
|
||||||
|
) -> go.Figure:
|
||||||
fig.update_layout(
|
fig.update_layout(
|
||||||
|
template=None,
|
||||||
height=height,
|
height=height,
|
||||||
margin=dict(l=24, r=24, t=42, b=28),
|
margin=dict(l=72, r=24, t=margin_top, b=margin_bottom),
|
||||||
paper_bgcolor="rgba(0,0,0,0)",
|
paper_bgcolor="rgba(0,0,0,0)",
|
||||||
plot_bgcolor=PLOT_BG,
|
plot_bgcolor=PLOT_BG,
|
||||||
font=dict(color=TEXT, family="Inter, system-ui, sans-serif"),
|
font=dict(color=TEXT, family="Inter, system-ui, sans-serif"),
|
||||||
hoverlabel=dict(bgcolor="#16232c", font_color="#ffffff"),
|
legend=dict(bgcolor="rgba(0,0,0,0)", font=dict(color=TEXT)),
|
||||||
|
hoverlabel=dict(
|
||||||
|
bgcolor="#16232c",
|
||||||
|
bordercolor="#36505d",
|
||||||
|
font_color="#ffffff",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
fig.update_xaxes(gridcolor=GRID, zeroline=False)
|
axis_style = dict(
|
||||||
fig.update_yaxes(gridcolor=GRID, zeroline=False)
|
color=TEXT,
|
||||||
|
gridcolor=GRID,
|
||||||
|
linecolor=GRID,
|
||||||
|
tickfont=dict(color=TEXT),
|
||||||
|
title_font=dict(color=TEXT),
|
||||||
|
zeroline=False,
|
||||||
|
)
|
||||||
|
fig.update_xaxes(**axis_style, automargin=True, title_standoff=12)
|
||||||
|
fig.update_yaxes(**axis_style, automargin=True, title_standoff=18)
|
||||||
return fig
|
return fig
|
||||||
|
|
||||||
|
|
||||||
def engine_heatmap(analysis: EngineAnalysis) -> go.Figure:
|
def engine_heatmap(analysis: EngineAnalysis) -> go.Figure:
|
||||||
cylinders = analysis.measurements["cylinder"].astype(int).tolist()
|
row_labels = [
|
||||||
max_abs = max(float(np.percentile(analysis.absolute_deviation, 95)), 1.0)
|
f"C{int(row.cylinder):02d} · {LABEL_DISPLAY[str(row.label)]}"
|
||||||
|
for row in analysis.diagnostics.itertuples()
|
||||||
|
]
|
||||||
fig = go.Figure(
|
fig = go.Figure(
|
||||||
go.Heatmap(
|
go.Heatmap(
|
||||||
z=analysis.deviation,
|
z=analysis.deviation,
|
||||||
x=list(range(len(FREQ_COLS))),
|
x=list(range(len(FREQ_COLS))),
|
||||||
y=[f"C{value:02d}" for value in cylinders],
|
y=row_labels,
|
||||||
zmin=-max_abs,
|
zmin=-HEATMAP_DEVIATION_LIMIT_MV,
|
||||||
zmax=max_abs,
|
zmax=HEATMAP_DEVIATION_LIMIT_MV,
|
||||||
zmid=0,
|
zmid=0,
|
||||||
colorscale=[
|
colorscale=[
|
||||||
[0.0, "#28b7d9"],
|
[0.0, "#28b7d9"],
|
||||||
[0.5, "#13222b"],
|
[0.5, "#13222b"],
|
||||||
[1.0, "#ff6b57"],
|
[1.0, "#ff6b57"],
|
||||||
],
|
],
|
||||||
colorbar=dict(title="Δ mV", thickness=12),
|
colorbar=dict(
|
||||||
hovertemplate="Cylinder %{y}<br>%{x} kHz<br>Odchylenie %{z:.1f} mV<extra></extra>",
|
title=dict(text="Δ mV", font=dict(color=TEXT)),
|
||||||
|
tickfont=dict(color=TEXT),
|
||||||
|
bgcolor="rgba(0,0,0,0)",
|
||||||
|
borderwidth=0,
|
||||||
|
thickness=12,
|
||||||
|
),
|
||||||
|
hovertemplate="%{y}<br>%{x} kHz<br>Odchylenie %{z:.1f} mV<extra></extra>",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
fig.update_layout(title="Engine Diagnostic Fingerprint")
|
fig.update_yaxes(autorange="reversed", automargin=True, title=None)
|
||||||
fig.update_yaxes(autorange="reversed", title="Cylinder")
|
|
||||||
fig.update_xaxes(title="Częstotliwość [kHz]", dtick=2)
|
fig.update_xaxes(title="Częstotliwość [kHz]", dtick=2)
|
||||||
return _base_layout(fig, height=420)
|
return _base_layout(fig, height=360, margin_top=18, margin_bottom=42)
|
||||||
|
|
||||||
|
|
||||||
def cylinder_spectrum(analysis: EngineAnalysis, cylinder: int) -> go.Figure:
|
def _cylinder_position(analysis: EngineAnalysis, cylinder: int) -> int:
|
||||||
positions = np.flatnonzero(analysis.measurements["cylinder"].to_numpy() == cylinder)
|
positions = np.flatnonzero(analysis.measurements["cylinder"].to_numpy() == cylinder)
|
||||||
if len(positions) != 1:
|
if len(positions) != 1:
|
||||||
raise KeyError(f"Unknown cylinder={cylinder}")
|
raise KeyError(f"Unknown cylinder={cylinder}")
|
||||||
position = int(positions[0])
|
return int(positions[0])
|
||||||
|
|
||||||
|
|
||||||
|
def cylinder_spectrum(
|
||||||
|
analysis: EngineAnalysis,
|
||||||
|
cylinder: int,
|
||||||
|
comparison_cylinders: Sequence[int] | None = None,
|
||||||
|
) -> go.Figure:
|
||||||
|
selected = list(
|
||||||
|
dict.fromkeys([cylinder, *(comparison_cylinders or ())])
|
||||||
|
)
|
||||||
|
if len(selected) > 4:
|
||||||
|
raise ValueError("At most four cylinders can be compared.")
|
||||||
|
|
||||||
|
position = _cylinder_position(analysis, cylinder)
|
||||||
row = analysis.diagnostics.iloc[position]
|
row = analysis.diagnostics.iloc[position]
|
||||||
color = LABEL_COLORS[str(row["label"])]
|
primary_color = LABEL_COLORS[str(row["label"])]
|
||||||
frequency = np.arange(len(FREQ_COLS))
|
frequency = np.arange(len(FREQ_COLS))
|
||||||
fig = go.Figure()
|
fig = go.Figure()
|
||||||
fig.add_trace(
|
fig.add_trace(
|
||||||
@ -69,20 +113,33 @@ def cylinder_spectrum(analysis: EngineAnalysis, cylinder: int) -> go.Figure:
|
|||||||
x=frequency,
|
x=frequency,
|
||||||
y=analysis.reference[position],
|
y=analysis.reference[position],
|
||||||
mode="lines",
|
mode="lines",
|
||||||
name="Mediana pozostałych cylindrów",
|
name=f"Referencja C{cylinder:02d}",
|
||||||
line=dict(color="#7f95a1", width=2, dash="dash"),
|
line=dict(color="#7f95a1", width=2, dash="dash"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
extra_colors = iter(
|
||||||
|
color
|
||||||
|
for color in COMPARISON_COLORS
|
||||||
|
if color.lower() != primary_color.lower()
|
||||||
|
)
|
||||||
|
for selected_cylinder in selected:
|
||||||
|
selected_position = _cylinder_position(analysis, selected_cylinder)
|
||||||
|
color = primary_color if selected_cylinder == cylinder else next(extra_colors)
|
||||||
fig.add_trace(
|
fig.add_trace(
|
||||||
go.Scatter(
|
go.Scatter(
|
||||||
x=frequency,
|
x=frequency,
|
||||||
y=analysis.spectra[position],
|
y=analysis.spectra[selected_position],
|
||||||
mode="lines+markers",
|
mode="lines+markers",
|
||||||
name=f"Cylinder {cylinder}",
|
name=f"C{selected_cylinder:02d}",
|
||||||
line=dict(color=color, width=3),
|
line=dict(
|
||||||
marker=dict(size=6),
|
color=color,
|
||||||
|
width=3 if selected_cylinder == cylinder else 2,
|
||||||
|
),
|
||||||
|
marker=dict(size=6 if selected_cylinder == cylinder else 4),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
top = {
|
top = {
|
||||||
int(value)
|
int(value)
|
||||||
for value in str(row["top_anomalous_frequencies_khz"]).split("|")
|
for value in str(row["top_anomalous_frequencies_khz"]).split("|")
|
||||||
@ -92,14 +149,22 @@ def cylinder_spectrum(analysis: EngineAnalysis, cylinder: int) -> go.Figure:
|
|||||||
fig.add_vrect(
|
fig.add_vrect(
|
||||||
x0=value - 0.35,
|
x0=value - 0.35,
|
||||||
x1=value + 0.35,
|
x1=value + 0.35,
|
||||||
fillcolor=color,
|
fillcolor=primary_color,
|
||||||
opacity=0.10,
|
opacity=0.10,
|
||||||
line_width=0,
|
line_width=0,
|
||||||
)
|
)
|
||||||
fig.update_layout(title="Widmo cylindra vs referencja", legend=dict(orientation="h", y=1.14))
|
fig.update_layout(
|
||||||
|
legend=dict(
|
||||||
|
orientation="h",
|
||||||
|
x=0,
|
||||||
|
xanchor="left",
|
||||||
|
y=1.04,
|
||||||
|
yanchor="bottom",
|
||||||
|
)
|
||||||
|
)
|
||||||
fig.update_xaxes(title="Częstotliwość [kHz]", dtick=1)
|
fig.update_xaxes(title="Częstotliwość [kHz]", dtick=1)
|
||||||
fig.update_yaxes(title="Amplituda [mV]")
|
fig.update_yaxes(title="Amplituda [mV]")
|
||||||
return _base_layout(fig, height=410)
|
return _base_layout(fig, height=440, margin_top=76, margin_bottom=42)
|
||||||
|
|
||||||
|
|
||||||
def deviation_chart(analysis: EngineAnalysis, cylinder: int) -> go.Figure:
|
def deviation_chart(analysis: EngineAnalysis, cylinder: int) -> go.Figure:
|
||||||
@ -117,7 +182,6 @@ def deviation_chart(analysis: EngineAnalysis, cylinder: int) -> go.Figure:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
fig.add_hline(y=0, line_color="#6e8794", line_width=1)
|
fig.add_hline(y=0, line_color="#6e8794", line_width=1)
|
||||||
fig.update_layout(title="Odchylenie od referencji")
|
|
||||||
fig.update_xaxes(title="Częstotliwość [kHz]", dtick=2)
|
fig.update_xaxes(title="Częstotliwość [kHz]", dtick=2)
|
||||||
fig.update_yaxes(title="Różnica [mV]")
|
fig.update_yaxes(title="Różnica [mV]")
|
||||||
return _base_layout(fig, height=320)
|
return _base_layout(fig, height=340, margin_top=24, margin_bottom=42)
|
||||||
|
|||||||
@ -4,9 +4,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from benchmark_grouped import FAULT_LABELS, FREQ_COLS, LABELS, NOT_APPLICABLE, SEVERITIES
|
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"
|
||||||
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
171
engin/features.py
Normal 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
205
engin/inference.py
Normal 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",
|
||||||
|
]
|
||||||
@ -3,13 +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 DiagnosticModels, predict_test, train_models, validate_submission
|
from .artifact import ArtifactMetadata, load_model_artifact
|
||||||
|
|
||||||
from .errors import InferenceError
|
from .errors import InferenceError
|
||||||
|
from .inference import DiagnosticModels, predict_test, validate_submission
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@ -23,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:
|
||||||
@ -58,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)
|
||||||
|
|
||||||
|
|
||||||
@ -73,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(
|
|
||||||
self._submission.copy(),
|
|
||||||
self._diagnostics.copy(),
|
|
||||||
)
|
)
|
||||||
|
diagnostics = self._diagnostics.copy()
|
||||||
|
diagnostics["model_version"] = "demo-precomputed"
|
||||||
|
diagnostics["model_artifact_sha256"] = "unavailable"
|
||||||
|
return PredictionBundle(self._submission.copy(), diagnostics)
|
||||||
|
|||||||
@ -8,7 +8,7 @@ from typing import Protocol
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
from .config import AppConfig, FREQ_COLS
|
from .config import FREQ_COLS, AppConfig
|
||||||
from .errors import InputDataError
|
from .errors import InputDataError
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -13,48 +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,
|
||||||
make_pipeline,
|
OOD_OK_TO_UNKNOWN_THRESHOLD_MV,
|
||||||
validate_data,
|
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:
|
||||||
@ -77,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"))
|
||||||
@ -135,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:
|
||||||
@ -212,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,
|
||||||
|
|||||||
@ -19,8 +19,8 @@ from benchmark_grouped import (
|
|||||||
FAULT_LABELS,
|
FAULT_LABELS,
|
||||||
LABELS,
|
LABELS,
|
||||||
NOT_APPLICABLE,
|
NOT_APPLICABLE,
|
||||||
make_splits,
|
|
||||||
macro_f1,
|
macro_f1,
|
||||||
|
make_splits,
|
||||||
ml_points,
|
ml_points,
|
||||||
raw_score,
|
raw_score,
|
||||||
validate_data,
|
validate_data,
|
||||||
@ -38,7 +38,6 @@ from severity_benchmark import (
|
|||||||
prepare_labeled_frame,
|
prepare_labeled_frame,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_SEEDS = [7, 21, 42, 77, 123]
|
DEFAULT_SEEDS = [7, 21, 42, 77, 123]
|
||||||
|
|
||||||
|
|
||||||
@ -166,7 +165,7 @@ def main() -> None:
|
|||||||
"ood_overrides": ood_count[scenario],
|
"ood_overrides": ood_count[scenario],
|
||||||
}
|
}
|
||||||
row.update(
|
row.update(
|
||||||
{f"f1_{label}": float(value) for label, value in zip(LABELS, per_class)}
|
{f"f1_{label}": float(value) for label, value in zip(LABELS, per_class, strict=True)}
|
||||||
)
|
)
|
||||||
run_rows.append(row)
|
run_rows.append(row)
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
5
ruff.toml
Normal file
5
ruff.toml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
target-version = "py312"
|
||||||
|
extend-exclude = ["archive/legacy_experiments"]
|
||||||
|
|
||||||
|
[lint]
|
||||||
|
select = ["E4", "E7", "E9", "F", "I", "B"]
|
||||||
1
scripts/__init__.py
Normal file
1
scripts/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Operational scripts for building and verifying ENGIN releases."""
|
||||||
130
scripts/build_model_artifact.py
Normal file
130
scripts/build_model_artifact.py
Normal 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()
|
||||||
@ -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
|
||||||
|
|||||||
@ -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."""
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,6 @@ from pathlib import Path
|
|||||||
|
|
||||||
from streamlit.testing.v1 import AppTest
|
from streamlit.testing.v1 import AppTest
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
@ -13,9 +12,72 @@ class StreamlitSmokeTests(unittest.TestCase):
|
|||||||
def test_default_demo_renders_without_exception(self) -> None:
|
def test_default_demo_renders_without_exception(self) -> None:
|
||||||
app = AppTest.from_file(str(ROOT / "app.py"), default_timeout=30).run()
|
app = AppTest.from_file(str(ROOT / "app.py"), default_timeout=30).run()
|
||||||
self.assertEqual(len(app.exception), 0)
|
self.assertEqual(len(app.exception), 0)
|
||||||
self.assertGreaterEqual(len(app.button), 8)
|
self.assertGreaterEqual(len(app.selectbox), 1)
|
||||||
self.assertGreaterEqual(len(app.selectbox), 2)
|
|
||||||
self.assertEqual(app.radio[0].value, "Dane demonstracyjne")
|
self.assertEqual(app.radio[0].value, "Dane demonstracyjne")
|
||||||
|
self.assertEqual(app.segmented_control[0].value, "Przegląd")
|
||||||
|
self.assertEqual(
|
||||||
|
app.segmented_control[0].options,
|
||||||
|
["Przegląd", "Szczegóły cylindra"],
|
||||||
|
)
|
||||||
|
stylesheet = (ROOT / "assets" / "app.css").read_text(encoding="utf-8")
|
||||||
|
self.assertIn(
|
||||||
|
"grid-template-columns: repeat(2, minmax(0, 1fr))",
|
||||||
|
stylesheet,
|
||||||
|
)
|
||||||
|
self.assertFalse(any("C01" in button.label for button in app.button))
|
||||||
|
self.assertFalse(
|
||||||
|
any(selector.label == "Cylinder do analizy" for selector in app.selectbox)
|
||||||
|
)
|
||||||
|
rendered = "\n".join(markdown.value for markdown in app.markdown)
|
||||||
|
self.assertIn("Konsola diagnostyczna ENGIN", rendered)
|
||||||
|
self.assertNotIn("MODEL GOTOWY", rendered)
|
||||||
|
self.assertNotIn("ARTEFAKT ZWERYFIKOWANY", rendered)
|
||||||
|
self.assertNotIn("CPU · DANE LOKALNE", rendered)
|
||||||
|
for english_fragment in (
|
||||||
|
"Diagnostic Console",
|
||||||
|
"LIVE INFERENCE",
|
||||||
|
"DEMO FALLBACK",
|
||||||
|
"LEAKAGE-SAFE",
|
||||||
|
"Grouped Macro F1",
|
||||||
|
"Severity accuracy",
|
||||||
|
):
|
||||||
|
self.assertNotIn(english_fragment, rendered)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[metric.label for metric in app.metric],
|
||||||
|
[
|
||||||
|
"Makro F1 (grupowe)",
|
||||||
|
"Trafność nasilenia",
|
||||||
|
"Punkty walidacyjne",
|
||||||
|
"Testy",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
technical_expander = next(
|
||||||
|
expander for expander in app.expander if expander.label == "Informacje techniczne"
|
||||||
|
)
|
||||||
|
self.assertFalse(technical_expander.proto.expanded)
|
||||||
|
diagnostic_table = next(
|
||||||
|
table
|
||||||
|
for table in app.dataframe
|
||||||
|
if "Źródło decyzji" in table.value.columns
|
||||||
|
)
|
||||||
|
diagnostic_columns = set(diagnostic_table.value.columns)
|
||||||
|
self.assertIn("Źródło decyzji", diagnostic_columns)
|
||||||
|
self.assertNotIn("decision_source", diagnostic_columns)
|
||||||
|
overview_table = next(
|
||||||
|
table for table in app.dataframe if "Kolejność" in table.value.columns
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
list(overview_table.value.columns),
|
||||||
|
[
|
||||||
|
"Kolejność",
|
||||||
|
"Cylinder",
|
||||||
|
"Diagnoza",
|
||||||
|
"Nasilenie",
|
||||||
|
"Odchylenie [mV]",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertNotIn("Priorytet", overview_table.value.columns)
|
||||||
|
|
||||||
def test_upload_mode_has_safe_empty_state(self) -> None:
|
def test_upload_mode_has_safe_empty_state(self) -> None:
|
||||||
app = AppTest.from_file(str(ROOT / "app.py"), default_timeout=30).run()
|
app = AppTest.from_file(str(ROOT / "app.py"), default_timeout=30).run()
|
||||||
@ -23,13 +85,40 @@ class StreamlitSmokeTests(unittest.TestCase):
|
|||||||
self.assertEqual(len(app.exception), 0)
|
self.assertEqual(len(app.exception), 0)
|
||||||
self.assertGreaterEqual(len(app.info), 1)
|
self.assertGreaterEqual(len(app.info), 1)
|
||||||
|
|
||||||
def test_cylinder_grid_and_detail_selector_share_state(self) -> None:
|
def test_cylinder_selector_exists_only_in_detail_view(self) -> None:
|
||||||
app = AppTest.from_file(str(ROOT / "app.py"), default_timeout=30).run()
|
app = AppTest.from_file(str(ROOT / "app.py"), default_timeout=30).run()
|
||||||
target = next(button for button in app.button if "C03" in button.label)
|
self.assertFalse(
|
||||||
target.click().run()
|
any(selector.label == "Cylinder do analizy" for selector in app.selectbox)
|
||||||
|
)
|
||||||
|
app.segmented_control[0].set_value("Szczegóły cylindra").run()
|
||||||
|
self.assertEqual(app.segmented_control[0].value, "Szczegóły cylindra")
|
||||||
detail_selector = next(
|
detail_selector = next(
|
||||||
selector for selector in app.selectbox if selector.label == "Cylinder do analizy"
|
selector
|
||||||
|
for selector in app.selectbox
|
||||||
|
if selector.label == "Cylinder do analizy"
|
||||||
|
)
|
||||||
|
rendered = "\n".join(markdown.value for markdown in app.markdown)
|
||||||
|
self.assertIn(f"CYLINDER {int(detail_selector.value):02d}", rendered)
|
||||||
|
detail_selector.set_value(3).run()
|
||||||
|
detail_selector = next(
|
||||||
|
selector
|
||||||
|
for selector in app.selectbox
|
||||||
|
if selector.label == "Cylinder do analizy"
|
||||||
)
|
)
|
||||||
self.assertEqual(detail_selector.value, 3)
|
self.assertEqual(detail_selector.value, 3)
|
||||||
|
for slot, cylinder in enumerate((1, 2, 4), start=1):
|
||||||
|
comparison = next(
|
||||||
|
selector
|
||||||
|
for selector in app.selectbox
|
||||||
|
if selector.label == f"Porównanie {slot}"
|
||||||
|
)
|
||||||
|
comparison.set_value(cylinder).run()
|
||||||
|
self.assertEqual(len(app.exception), 0)
|
||||||
|
self.assertEqual(len(app.multiselect), 0)
|
||||||
rendered = "\n".join(markdown.value for markdown in app.markdown)
|
rendered = "\n".join(markdown.value for markdown in app.markdown)
|
||||||
self.assertIn("CYLINDER 03", rendered)
|
self.assertIn("CYLINDER 03", rendered)
|
||||||
|
self.assertIn("### Wybór cylindra", rendered)
|
||||||
|
self.assertIn("#### Uzasadnienie diagnozy", rendered)
|
||||||
|
self.assertNotIn("#### Następny krok", rendered)
|
||||||
|
self.assertNotIn("Select all", rendered)
|
||||||
|
self.assertNotIn("You can select up to", rendered)
|
||||||
|
|||||||
@ -14,7 +14,6 @@ from engin.explainability import (
|
|||||||
)
|
)
|
||||||
from engin.service import DiagnosisResult
|
from engin.service import DiagnosisResult
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
@ -63,6 +62,29 @@ class ExplainabilityTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_all_chart_factories_return_populated_figures(self) -> None:
|
def test_all_chart_factories_return_populated_figures(self) -> None:
|
||||||
cylinder = int(self.analysis.measurements["cylinder"].iloc[0])
|
cylinder = int(self.analysis.measurements["cylinder"].iloc[0])
|
||||||
self.assertEqual(len(engine_heatmap(self.analysis).data), 1)
|
compared = self.analysis.measurements["cylinder"].astype(int).head(4).tolist()
|
||||||
self.assertEqual(len(cylinder_spectrum(self.analysis, cylinder).data), 2)
|
heatmap = engine_heatmap(self.analysis)
|
||||||
|
self.assertEqual(len(heatmap.data), 1)
|
||||||
|
self.assertEqual(heatmap.layout.height, 360)
|
||||||
|
self.assertEqual(heatmap.data[0].zmin, -20.0)
|
||||||
|
self.assertEqual(heatmap.data[0].zmax, 20.0)
|
||||||
|
self.assertTrue(any("Sprawny" in label for label in heatmap.data[0].y))
|
||||||
|
self.assertTrue(any("·" in label for label in heatmap.data[0].y))
|
||||||
|
self.assertTrue(all(label.startswith("C") for label in heatmap.data[0].y))
|
||||||
|
self.assertEqual(heatmap.layout.plot_bgcolor, "#101820")
|
||||||
|
self.assertEqual(heatmap.data[0].colorbar.bgcolor, "rgba(0,0,0,0)")
|
||||||
|
self.assertEqual(heatmap.data[0].colorbar.tickfont.color, "#dce8ee")
|
||||||
|
spectrum = cylinder_spectrum(self.analysis, cylinder)
|
||||||
|
self.assertEqual(spectrum.layout.margin.l, 72)
|
||||||
|
self.assertTrue(spectrum.layout.yaxis.automargin)
|
||||||
|
self.assertEqual(spectrum.layout.yaxis.title.standoff, 18)
|
||||||
|
deviation = deviation_chart(self.analysis, cylinder)
|
||||||
|
self.assertEqual(deviation.layout.margin.l, 72)
|
||||||
|
self.assertTrue(deviation.layout.yaxis.automargin)
|
||||||
|
self.assertEqual(deviation.layout.yaxis.title.standoff, 18)
|
||||||
|
self.assertEqual(len(spectrum.data), 2)
|
||||||
|
self.assertEqual(
|
||||||
|
len(cylinder_spectrum(self.analysis, cylinder, compared).data),
|
||||||
|
5,
|
||||||
|
)
|
||||||
self.assertEqual(len(deviation_chart(self.analysis, cylinder).data), 1)
|
self.assertEqual(len(deviation_chart(self.analysis, cylinder).data), 1)
|
||||||
|
|||||||
114
tests/test_model_artifact.py
Normal file
114
tests/test_model_artifact.py
Normal 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)
|
||||||
@ -4,7 +4,7 @@ import unittest
|
|||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
from engin.model import PredictionBundle, PrecomputedPredictionModel
|
from engin.model import PrecomputedPredictionModel, PredictionBundle
|
||||||
from engin.service import DiagnosticService
|
from engin.service import DiagnosticService
|
||||||
from engin.validation import ValidationResult
|
from engin.validation import ValidationResult
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,6 @@ from engin.config import FREQ_COLS
|
|||||||
from engin.errors import InputDataError
|
from engin.errors import InputDataError
|
||||||
from engin.validation import SpectrumFrameValidator
|
from engin.validation import SpectrumFrameValidator
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user