ML Platform — инфраструктура и инструменты для полного жизненного цикла ML: от экспериментов до production. Платформа решает проблему: «Модель работает в notebook, но как доставить её в production?»
Компоненты ML Platform
Компонент
Назначение
Примеры инструментов
Experiment Tracking
Логирование экспериментов
MLflow, W&B, Neptune
Feature Store
Хранение и serving features
Feast, Tecton, Hopsworks
Model Registry
Версионирование моделей
MLflow, Vertex AI
Training Infrastructure
GPU/TPU для обучения
Kubernetes, Ray
Model Serving
Production inference
KServe, Triton, TorchServe
Monitoring
Drift, quality, latency
Evidently, NannyML, Prometheus
Data Pipeline
ETL для ML
Airflow, Prefect, Dagster
CI/CD for ML
Автоматизация pipeline
GitHub Actions, Jenkins
MLOps
MLOps — применение DevOps-практик к ML-проектам. Автоматизация процесса от данных до production.
Уровни зрелости MLOps
Уровень
Описание
Характеристика
0
Manual
Notebook → ручной деплой
1
ML Pipeline
Автоматизированный training
2
CI/CD Pipeline
Автоматический ретрейнинг + деплой
3
Full Automation
Continuous training + monitoring + auto-retrain
CI/CD for ML
В отличие от обычного CI/CD, ML CI/CD включает проверку данных и модели.
Code Change: Code → Lint → Test → Build → Deploy
ML Pipeline: Data → Validate → Train → Evaluate → Register → Deploy → Monitor
↓ ↓ ↓ ↓
Data tests Schema Model Quality
Quality check tests gates
Model Registry
Model Registry — версионированное хранилище обученных моделей с метаданными.
Клиент для Model Registry
<?php
declare(strict_types=1);
namespace App\ML;
/**
* Client for interacting with a Model Registry (e.g., MLflow).
*/
final readonly class ModelRegistryClient
{
public function __construct(
private string $registryUrl,
) {}
/**
* Get the latest production model version.
*
* @return ModelVersion
*/
public function getProductionModel(string $modelName): ModelVersion
{
$response = $this->request(
"/api/2.0/mlflow/registered-models/get-latest-versions",
['name' => $modelName, 'stages' => ['Production']],
);
$versions = $response['model_versions'] ?? [];
if (empty($versions)) {
throw new \RuntimeException("No production model found for '{$modelName}'");
}
$latest = $versions[0];
return new ModelVersion(
name: $latest['name'],
version: (int) $latest['version'],
stage: $latest['current_stage'],
runId: $latest['run_id'],
artifactUri: $latest['source'],
createdAt: new \DateTimeImmutable('@' . ($latest['creation_timestamp'] / 1000)),
);
}
/**
* Transition model version to a new stage.
*/
public function transitionStage(
string $modelName,
int $version,
string $stage, // 'Staging', 'Production', 'Archived'
): void {
$this->request(
"/api/2.0/mlflow/model-versions/transition-stage",
[
'name' => $modelName,
'version' => (string) $version,
'stage' => $stage,
'archive_existing_versions' => true,
],
);
}
/**
* List all model versions.
*
* @return array<ModelVersion>
*/
public function listVersions(string $modelName): array
{
$response = $this->request(
"/api/2.0/mlflow/model-versions/search",
['filter' => "name='{$modelName}'"],
);
return array_map(
static fn(array $v) => new ModelVersion(
name: $v['name'],
version: (int) $v['version'],
stage: $v['current_stage'],
runId: $v['run_id'],
artifactUri: $v['source'],
createdAt: new \DateTimeImmutable('@' . ($v['creation_timestamp'] / 1000)),
),
$response['model_versions'] ?? [],
);
}
private function request(string $endpoint, array $data): array
{
$ch = curl_init($this->registryUrl . $endpoint);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($data),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
]);
$response = curl_exec($ch);
curl_close($ch);
return json_decode($response, true, 512, JSON_THROW_ON_ERROR);
}
}
final readonly class ModelVersion
{
public function __construct(
public string $name,
public int $version,
public string $stage,
public string $runId,
public string $artifactUri,
public \DateTimeImmutable $createdAt,
) {}
}
package ml
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// ModelVersion represents a versioned model in the registry.
type ModelVersion struct {
Name string
Version int
Stage string
RunID string
ArtifactURI string
CreatedAt time.Time
}
// ModelRegistryClient communicates with a Model Registry (e.g., MLflow).
type ModelRegistryClient struct {
registryURL string
httpClient *http.Client
}
// NewModelRegistryClient creates a registry client.
func NewModelRegistryClient(registryURL string) *ModelRegistryClient {
return &ModelRegistryClient{
registryURL: registryURL,
httpClient: &http.Client{Timeout: 30 * time.Second},
}
}
// GetProductionModel returns the latest production model version.
func (c *ModelRegistryClient) GetProductionModel(ctx context.Context, modelName string) (ModelVersion, error) {
var resp struct {
ModelVersions []struct {
Name string `json:"name"`
Version string `json:"version"`
CurrentStage string `json:"current_stage"`
RunID string `json:"run_id"`
Source string `json:"source"`
CreationTimestamp int64 `json:"creation_timestamp"`
} `json:"model_versions"`
}
err := c.request(ctx, "/api/2.0/mlflow/registered-models/get-latest-versions", map[string]any{
"name": modelName, "stages": []string{"Production"},
}, &resp)
if err != nil {
return ModelVersion{}, fmt.Errorf("get production model: %w", err)
}
if len(resp.ModelVersions) == 0 {
return ModelVersion{}, fmt.Errorf("no production model found for %q", modelName)
}
v := resp.ModelVersions[0]
ver := 0
fmt.Sscanf(v.Version, "%d", &ver)
return ModelVersion{
Name: v.Name,
Version: ver,
Stage: v.CurrentStage,
RunID: v.RunID,
ArtifactURI: v.Source,
CreatedAt: time.UnixMilli(v.CreationTimestamp),
}, nil
}
// TransitionStage moves a model version to a new stage.
func (c *ModelRegistryClient) TransitionStage(ctx context.Context, modelName string, version int, stage string) error {
return c.request(ctx, "/api/2.0/mlflow/model-versions/transition-stage", map[string]any{
"name": modelName,
"version": fmt.Sprintf("%d", version),
"stage": stage,
"archive_existing_versions": true,
}, nil)
}
// ListVersions returns all versions for a model.
func (c *ModelRegistryClient) ListVersions(ctx context.Context, modelName string) ([]ModelVersion, error) {
var resp struct {
ModelVersions []struct {
Name string `json:"name"`
Version string `json:"version"`
CurrentStage string `json:"current_stage"`
RunID string `json:"run_id"`
Source string `json:"source"`
CreationTimestamp int64 `json:"creation_timestamp"`
} `json:"model_versions"`
}
err := c.request(ctx, "/api/2.0/mlflow/model-versions/search", map[string]any{
"filter": fmt.Sprintf("name='%s'", modelName),
}, &resp)
if err != nil {
return nil, fmt.Errorf("list versions: %w", err)
}
versions := make([]ModelVersion, len(resp.ModelVersions))
for i, v := range resp.ModelVersions {
ver := 0
fmt.Sscanf(v.Version, "%d", &ver)
versions[i] = ModelVersion{
Name: v.Name,
Version: ver,
Stage: v.CurrentStage,
RunID: v.RunID,
ArtifactURI: v.Source,
CreatedAt: time.UnixMilli(v.CreationTimestamp),
}
}
return versions, nil
}
func (c *ModelRegistryClient) request(ctx context.Context, endpoint string, payload any, result any) error {
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.registryURL+endpoint, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("execute request: %w", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if result != nil {
return json.Unmarshal(respBody, result)
}
return nil
}
using System.Net.Http.Json;
using System.Text.Json.Serialization;
namespace App.Ml;
/// Represents a versioned model in the registry.
public sealed record ModelVersion(
string Name,
int Version,
string Stage,
string RunId,
string ArtifactUri,
DateTimeOffset CreatedAt);
/// Client for interacting with a Model Registry (e.g., MLflow).
public sealed class ModelRegistryClient
{
private readonly HttpClient _http;
// BaseAddress and timeout come from IHttpClientFactory configuration.
public ModelRegistryClient(HttpClient http) => _http = http;
// Get the latest production model version.
public async Task<ModelVersion> GetProductionModelAsync(
string modelName,
CancellationToken ct = default)
{
var response = await RequestAsync<VersionsResponse>(
"api/2.0/mlflow/registered-models/get-latest-versions",
new { name = modelName, stages = new[] { "Production" } },
ct);
var latest = response.ModelVersions.FirstOrDefault()
?? throw new InvalidOperationException($"No production model found for '{modelName}'");
return Map(latest);
}
// Transition a model version to a new stage: Staging, Production or Archived.
public Task TransitionStageAsync(
string modelName,
int version,
string stage,
CancellationToken ct = default)
=> RequestAsync<object>(
"api/2.0/mlflow/model-versions/transition-stage",
new
{
name = modelName,
version = version.ToString(),
stage,
archive_existing_versions = true,
},
ct);
// List all versions for a model.
public async Task<IReadOnlyList<ModelVersion>> ListVersionsAsync(
string modelName,
CancellationToken ct = default)
{
var response = await RequestAsync<VersionsResponse>(
"api/2.0/mlflow/model-versions/search",
new { filter = $"name='{modelName}'" },
ct);
return response.ModelVersions.Select(Map).ToArray();
}
private static ModelVersion Map(RawModelVersion raw) => new(
raw.Name,
int.TryParse(raw.Version, out var version) ? version : 0,
raw.CurrentStage,
raw.RunId,
raw.Source,
DateTimeOffset.FromUnixTimeMilliseconds(raw.CreationTimestamp));
private async Task<T> RequestAsync<T>(string endpoint, object payload, CancellationToken ct)
{
using var response = await _http.PostAsJsonAsync(endpoint, payload, ct);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<T>(ct)
?? throw new InvalidOperationException("Empty registry response");
}
private sealed record VersionsResponse(
[property: JsonPropertyName("model_versions")] IReadOnlyList<RawModelVersion> ModelVersions);
private sealed record RawModelVersion(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("version")] string Version,
[property: JsonPropertyName("current_stage")] string CurrentStage,
[property: JsonPropertyName("run_id")] string RunId,
[property: JsonPropertyName("source")] string Source,
[property: JsonPropertyName("creation_timestamp")] long CreationTimestamp);
}
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
import httpx
@dataclass(frozen=True, slots=True)
class ModelVersion:
"""Represents a versioned model in the registry."""
name: str
version: int
stage: str
run_id: str
artifact_uri: str
created_at: datetime
class ModelRegistryClient:
"""Client for interacting with a Model Registry (e.g., MLflow)."""
def __init__(self, http: httpx.AsyncClient, registry_url: str) -> None:
self._http = http
self._registry_url = registry_url.rstrip("/")
async def get_production_model(self, model_name: str) -> ModelVersion:
"""Get the latest production model version."""
response = await self._request(
"/api/2.0/mlflow/registered-models/get-latest-versions",
{"name": model_name, "stages": ["Production"]},
)
versions = response.get("model_versions", [])
if not versions:
raise RuntimeError(f"No production model found for '{model_name}'")
return self._map(versions[0])
async def transition_stage(
self,
model_name: str,
version: int,
stage: str,
) -> None:
"""Move a model version to a new stage: Staging, Production or Archived."""
await self._request(
"/api/2.0/mlflow/model-versions/transition-stage",
{
"name": model_name,
"version": str(version),
"stage": stage,
"archive_existing_versions": True,
},
)
async def list_versions(self, model_name: str) -> list[ModelVersion]:
"""List all versions for a model."""
response = await self._request(
"/api/2.0/mlflow/model-versions/search",
{"filter": f"name='{model_name}'"},
)
return [self._map(v) for v in response.get("model_versions", [])]
@staticmethod
def _map(raw: dict[str, Any]) -> ModelVersion:
return ModelVersion(
name=raw["name"],
version=int(raw["version"]),
stage=raw["current_stage"],
run_id=raw["run_id"],
artifact_uri=raw["source"],
created_at=datetime.fromtimestamp(
raw["creation_timestamp"] / 1000,
tz=timezone.utc,
),
)
async def _request(self, endpoint: str, payload: dict[str, Any]) -> dict[str, Any]:
response = await self._http.post(f"{self._registry_url}{endpoint}", json=payload)
response.raise_for_status()
return response.json()
## Experiment Tracking
Experiment tracking — систематическое логирование параметров, метрик и артефактов каждого эксперимента.
Что логировать
Категория
Примеры
Parameters
learning_rate, batch_size, epochs
Metrics
accuracy, loss, F1, AUC
Artifacts
Модель, графики, confusion matrix
Environment
Python version, GPU, dependencies
Data
Dataset version, split ratio, size
Tags
experiment_type, author, ticket
Data Versioning
Инструмент
Описание
DVC
Git for data — версионирование датасетов
Delta Lake
ACID-транзакции для data lakes
LakeFS
Git-like operations для object storage
Model Deployment Patterns
Паттерн
Описание
Когда
Shadow deployment
Новая модель работает параллельно, результаты не используются
package ml
import (
"context"
"crypto/md5"
"encoding/json"
"fmt"
"math"
"sort"
"time"
"github.com/redis/go-redis/v9"
)
// PredictionLogger logs predictions to Redis for monitoring.
type PredictionLogger struct {
rdb *redis.Client
prefix string
}
// NewPredictionLogger creates a prediction logger.
func NewPredictionLogger(rdb *redis.Client) *PredictionLogger {
return &PredictionLogger{rdb: rdb, prefix: "ml_predictions:"}
}
// LogPrediction records a prediction for monitoring.
func (l *PredictionLogger) LogPrediction(ctx context.Context, modelName, modelVersion string, features map[string]float64, prediction any, confidence, latencyMs float64) error {
featJSON, _ := json.Marshal(features)
entry, _ := json.Marshal(map[string]any{
"timestamp": time.Now().Format(time.RFC3339),
"model": modelName,
"version": modelVersion,
"prediction": prediction,
"confidence": confidence,
"latency_ms": latencyMs,
"features_hash": fmt.Sprintf("%x", md5.Sum(featJSON)),
})
key := fmt.Sprintf("%s%s:%s", l.prefix, modelName, time.Now().Format("2006-01-02"))
pipe := l.rdb.Pipeline()
pipe.RPush(ctx, key, entry)
pipe.Expire(ctx, key, 7*24*time.Hour)
_, err := pipe.Exec(ctx)
return err
}
// PredictionStats holds aggregated prediction metrics.
type PredictionStats struct {
Count int
AvgConfidence float64
AvgLatencyMs float64
P99LatencyMs float64
}
// GetStats returns prediction statistics for a model on a given date.
func (l *PredictionLogger) GetStats(ctx context.Context, modelName, date string) (PredictionStats, error) {
key := fmt.Sprintf("%s%s:%s", l.prefix, modelName, date)
entries, err := l.rdb.LRange(ctx, key, 0, -1).Result()
if err != nil || len(entries) == 0 {
return PredictionStats{}, err
}
confidences := make([]float64, 0, len(entries))
latencies := make([]float64, 0, len(entries))
for _, raw := range entries {
var data struct {
Confidence float64 `json:"confidence"`
LatencyMs float64 `json:"latency_ms"`
}
if json.Unmarshal([]byte(raw), &data) == nil {
confidences = append(confidences, data.Confidence)
latencies = append(latencies, data.LatencyMs)
}
}
sort.Float64s(latencies)
p99Idx := int(float64(len(latencies)) * 0.99)
if p99Idx >= len(latencies) {
p99Idx = len(latencies) - 1
}
return PredictionStats{
Count: len(entries),
AvgConfidence: math.Round(sum(confidences)/float64(len(confidences))*10000) / 10000,
AvgLatencyMs: math.Round(sum(latencies)/float64(len(latencies))*100) / 100,
P99LatencyMs: math.Round(latencies[p99Idx]*100) / 100,
}, nil
}
func sum(vals []float64) float64 {
s := 0.0
for _, v := range vals {
s += v
}
return s
}
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using StackExchange.Redis;
namespace App.Ml;
/// Holds aggregated prediction metrics.
public readonly record struct PredictionStats(
int Count,
double AvgConfidence,
double AvgLatencyMs,
double P99LatencyMs);
/// Logs predictions to Redis for monitoring and analysis.
public sealed class PredictionLogger
{
private static readonly TimeSpan Retention = TimeSpan.FromDays(7);
private readonly IDatabase _redis;
private readonly string _prefix;
public PredictionLogger(IConnectionMultiplexer redis, string prefix = "ml_predictions:")
{
_redis = redis.GetDatabase();
_prefix = prefix;
}
// Log a prediction for monitoring.
public async Task LogPredictionAsync(
string modelName,
string modelVersion,
IReadOnlyDictionary<string, double> features,
object prediction,
double confidence,
double latencyMs)
{
var entry = JsonSerializer.Serialize(new
{
timestamp = DateTimeOffset.UtcNow.ToString("O"),
model = modelName,
version = modelVersion,
prediction,
confidence,
latency_ms = latencyMs,
features_hash = HashFeatures(features),
});
var key = $"{_prefix}{modelName}:{DateTime.UtcNow:yyyy-MM-dd}";
var batch = _redis.CreateBatch();
var push = batch.ListRightPushAsync(key, entry);
var expire = batch.KeyExpireAsync(key, Retention);
batch.Execute();
await Task.WhenAll(push, expire);
}
// Get prediction statistics for a monitoring dashboard.
public async Task<PredictionStats> GetStatsAsync(string modelName, string date)
{
var key = $"{_prefix}{modelName}:{date}";
var entries = await _redis.ListRangeAsync(key, 0, -1);
if (entries.Length == 0)
{
return new PredictionStats(0, 0, 0, 0);
}
var records = entries
.Select(e => JsonSerializer.Deserialize<LogEntry>(e!))
.OfType<LogEntry>()
.ToArray();
var latencies = records.Select(r => r.LatencyMs).Order().ToArray();
var p99Index = Math.Min((int)(latencies.Length * 0.99), latencies.Length - 1);
return new PredictionStats(
entries.Length,
Math.Round(records.Average(r => r.Confidence), 4),
Math.Round(latencies.Average(), 2),
Math.Round(latencies[p99Index], 2));
}
private static string HashFeatures(IReadOnlyDictionary<string, double> features)
{
var payload = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(features));
return Convert.ToHexString(MD5.HashData(payload)).ToLowerInvariant();
}
private sealed record LogEntry(double Confidence, double LatencyMs);
}
import hashlib
import json
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Mapping
from redis.asyncio import Redis
RETENTION_SECONDS = 7 * 24 * 60 * 60
@dataclass(frozen=True, slots=True)
class PredictionStats:
"""Holds aggregated prediction metrics."""
count: int
avg_confidence: float
avg_latency_ms: float
p99_latency_ms: float
class PredictionLogger:
"""Log predictions to Redis for monitoring and analysis."""
def __init__(self, redis: Redis, prefix: str = "ml_predictions:") -> None:
self._redis = redis
self._prefix = prefix
async def log_prediction(
self,
model_name: str,
model_version: str,
features: Mapping[str, float],
prediction: Any,
confidence: float,
latency_ms: float,
) -> None:
"""Log a prediction for monitoring."""
now = datetime.now(timezone.utc)
features_hash = hashlib.md5(
json.dumps(features, sort_keys=True).encode()
).hexdigest()
entry = json.dumps(
{
"timestamp": now.isoformat(),
"model": model_name,
"version": model_version,
"prediction": prediction,
"confidence": confidence,
"latency_ms": latency_ms,
"features_hash": features_hash,
}
)
key = f"{self._prefix}{model_name}:{now:%Y-%m-%d}"
# Pipeline keeps push and expire in a single round trip
async with self._redis.pipeline(transaction=True) as pipe:
pipe.rpush(key, entry)
pipe.expire(key, RETENTION_SECONDS)
await pipe.execute()
async def get_stats(self, model_name: str, date: str) -> PredictionStats:
"""Get prediction statistics for a monitoring dashboard."""
key = f"{self._prefix}{model_name}:{date}"
entries = await self._redis.lrange(key, 0, -1)
if not entries:
return PredictionStats(0, 0.0, 0.0, 0.0)
records = [json.loads(entry) for entry in entries]
confidences = [r["confidence"] for r in records]
latencies = sorted(r["latency_ms"] for r in records)
p99_index = min(int(len(latencies) * 0.99), len(latencies) - 1)
return PredictionStats(
count=len(records),
avg_confidence=round(sum(confidences) / len(confidences), 4),
avg_latency_ms=round(sum(latencies) / len(latencies), 2),
p99_latency_ms=round(latencies[p99_index], 2),
)
## Итоги
Концепция
Суть
ML Platform
Инфраструктура для полного ML lifecycle
MLOps
DevOps для ML: CI/CD, monitoring, automation
Model Registry
Версионирование и стадии моделей
Experiment Tracking
Логирование параметров и метрик
Data Versioning
Git-подобное версионирование данных
Production Monitoring
Drift detection + quality + latency
Для инженера: ML Platform — это software engineering задача. ML-инженеры строят модели, а platform engineers строят инфраструктуру для их надёжной работы в production.