Skip to content

09 · Building an ML Platform

Every module so far solved one team's problem for one model. An ML platform is the shared infrastructure that lets many teams train, serve, and monitor many models without each one reinventing Modules 01-08. This module covers what a platform actually standardizes: a model registry, a self-serve training/serving abstraction, and multi-tenant isolation.

A model registry: the single source of truth for "what's deployed where"

# registry.py — a minimal model registry backed by a database table
from dataclasses import dataclass
from datetime import datetime
from enum import Enum

class Stage(Enum):
    STAGING = "staging"
    PRODUCTION = "production"
    ARCHIVED = "archived"

@dataclass
class ModelVersion:
    name: str
    version: int
    stage: Stage
    artifact_uri: str          # e.g. s3://models/fraud-detector/v14/model.pkl
    metrics: dict
    registered_at: datetime
    registered_by: str

class ModelRegistry:
    def __init__(self):
        self._versions: dict[str, list[ModelVersion]] = {}

    def register(self, name: str, artifact_uri: str, metrics: dict, registered_by: str) -> ModelVersion:
        existing = self._versions.setdefault(name, [])
        version = len(existing) + 1
        mv = ModelVersion(name, version, Stage.STAGING, artifact_uri, metrics,
                           datetime.utcnow(), registered_by)
        existing.append(mv)
        return mv

    def promote(self, name: str, version: int, stage: Stage):
        for mv in self._versions[name]:
            if mv.stage == stage and stage == Stage.PRODUCTION:
                mv.stage = Stage.ARCHIVED   # only one PRODUCTION version at a time
            if mv.version == version:
                mv.stage = stage

    def get_production_model(self, name: str) -> ModelVersion | None:
        return next((mv for mv in self._versions.get(name, [])
                     if mv.stage == Stage.PRODUCTION), None)

registry = ModelRegistry()
v1 = registry.register("fraud-detector", "s3://models/fraud-detector/v1/", {"auc": 0.91}, "alice")
registry.promote("fraud-detector", version=1, stage=Stage.PRODUCTION)
print(registry.get_production_model("fraud-detector"))

This is the same promotion concept as Module 04's CI/CD gate, generalized across every model any team on the platform trains — instead of each team tracking "which model is live" in a spreadsheet or a teammate's memory, the registry is the one place that answers it programmatically, for any downstream tool (serving, monitoring, rollback) to query.

A self-serve training abstraction

# train_config.yaml — what a team submits; the platform handles the rest
model_name: fraud-detector
framework: sklearn
entry_point: train.py
compute:
  instance_type: cpu.large
  max_runtime_minutes: 60
data:
  source: s3://data-lake/fraud/training/
tracking:
  experiment_name: fraud-detector-experiments
# platform/submit_job.py — what the platform does with that config
def submit_training_job(config: dict, submitted_by: str) -> str:
    job_id = f"job-{config['model_name']}-{int(time.time())}"

    provision_compute(config["compute"]["instance_type"])          # Module 01/02 territory
    container = build_training_container(config["entry_point"])    # Module 05's Dockerfile pattern
    run_with_tracking(container, config["tracking"]["experiment_name"])  # Module 04 experiment tracking

    audit_log.write({"job_id": job_id, "submitted_by": submitted_by, "config": config})
    return job_id

A team writes train.py and a small YAML file; the platform handles provisioning, containerization, experiment tracking, and audit logging uniformly — the same infrastructure code serves every model, so improvements (a faster base image, a new tracking integration) benefit every team at once instead of requiring N teams to each adopt it.

Multi-tenant isolation: resource quotas

class TenantQuotaManager:
    def __init__(self):
        self.quotas = {}       # tenant -> {"cpu_hours": limit, "storage_gb": limit}
        self.usage = {}        # tenant -> current usage

    def set_quota(self, tenant: str, cpu_hours: float, storage_gb: float):
        self.quotas[tenant] = {"cpu_hours": cpu_hours, "storage_gb": storage_gb}
        self.usage.setdefault(tenant, {"cpu_hours": 0.0, "storage_gb": 0.0})

    def can_submit_job(self, tenant: str, estimated_cpu_hours: float) -> tuple[bool, str]:
        quota = self.quotas.get(tenant)
        if quota is None:
            return False, f"no quota configured for tenant {tenant!r}"
        used = self.usage[tenant]["cpu_hours"]
        if used + estimated_cpu_hours > quota["cpu_hours"]:
            return False, (
                f"would exceed cpu_hours quota: {used:.1f} + {estimated_cpu_hours:.1f} "
                f"> {quota['cpu_hours']:.1f}"
            )
        return True, "within quota"

    def record_usage(self, tenant: str, cpu_hours: float):
        self.usage[tenant]["cpu_hours"] += cpu_hours

qm = TenantQuotaManager()
qm.set_quota("team-fraud", cpu_hours=500, storage_gb=1000)
qm.record_usage("team-fraud", cpu_hours=470)
print(qm.can_submit_job("team-fraud", estimated_cpu_hours=50))
# (False, 'would exceed cpu_hours quota: 470.0 + 50.0 > 500.0')

Worked example: routing a prediction request through a multi-model platform

class PlatformRouter:
    """Given a model name, look up the current production version from the
    registry and route the request to its serving endpoint -- the layer
    that lets 'call fraud-detector' stay stable while versions rotate underneath."""

    def __init__(self, registry: ModelRegistry, endpoint_map: dict[str, str]):
        self.registry = registry
        self.endpoint_map = endpoint_map   # artifact_uri -> live endpoint URL

    def route(self, model_name: str, features: dict) -> dict:
        prod = self.registry.get_production_model(model_name)
        if prod is None:
            raise ValueError(f"no production version registered for {model_name!r}")

        endpoint = self.endpoint_map.get(prod.artifact_uri)
        if endpoint is None:
            raise RuntimeError(f"registry says v{prod.version} is production but no live endpoint found")

        return {"routed_to": endpoint, "model_version": prod.version, "request": features}

router = PlatformRouter(registry, {"s3://models/fraud-detector/v1/": "http://serving-v1.internal:8080"})
print(router.route("fraud-detector", {"amount": 250.0, "merchant_category": "electronics"}))

Callers depend only on the model name ("fraud-detector"), never a specific version or endpoint URL — a promotion in the registry (registry.promote(...)) instantly changes what every caller routes to, without any caller-side code change, which is the platform doing exactly what Module 04's canary rollout needs at the infrastructure layer.

Cheat sheet

Platform concern Building block
"What's deployed where, and who owns it" Model registry with staged promotion
Teams self-serve training without reinventing infra Declarative config + shared job-submission pipeline
Fair resource sharing across teams Per-tenant quotas enforced before job submission
Stable caller-facing names across version churn A router that resolves name → current production endpoint

How It Actually Works

A registry's value comes from being the single place that decouples "which version exists" from "which version is live," which every other platform piece can then depend on instead of re-deriving. Without a registry, "what's in production" lives implicitly in whatever config was last deployed to a server — invisible to a monitoring job, a rollback script, or a new teammate. get_production_model gives every consumer (the router, a monitoring dashboard, an audit tool) one authoritative query instead of each reimplementing "figure out what's live" by inspecting running infrastructure. The promote method's invariant — only one PRODUCTION version per model name at a time, enforced by auto-archiving the previous one — is what makes get_production_model safe to treat as returning a single unambiguous answer rather than a list callers have to disambiguate themselves.

The self-serve training abstraction works by pushing the variable part (what the model does) into a small user-owned file, while keeping the invariant part (how a job runs) as shared platform code. train.py and train_config.yaml are the only things a team writes; provisioning, containerization (Module 05's Dockerfile pattern generalized to build any entry_point), and tracking integration all live in platform/submit_job.py, owned by the platform team. This is a deliberate inversion from Module 05's single-team Dockerfile: there, one team's Dockerfile encodes one model's environment; here, the pattern is extracted into shared infrastructure so a fix or upgrade (e.g. a new base image with a security patch) is applied once, centrally, and takes effect for every team's next job submission — the platform is precisely the generalization of per-team plumbing into a shared, versioned service.

Quota enforcement has to happen before job submission, not after, because compute is far more expensive to claw back than to withhold. can_submit_job checks projected usage against the quota before any compute is provisioned — rejecting a job that would exceed quota costs nothing, whereas discovering an over-quota situation after a job has already consumed 400 CPU-hours means that cost is sunk regardless of what happens next. This mirrors the same "gate before the expensive step" pattern as Module 04's CI/CD pipeline (data validation runs before training, not after) and Module 06's sample-size calculation (computed before running the experiment, not after) — a recurring principle across this whole level: whenever an expensive or hard-to-reverse step is involved, the check that could prevent wasting it belongs strictly before it, not as a post-hoc audit.

Exercise

Extend PlatformRouter.route to fall back to the most recent STAGING version (with a logged warning) if no PRODUCTION version is registered, instead of raising. Then extend TenantQuotaManager with a second resource dimension, gpu_hours, with a stricter default quota than cpu_hours, and explain — referencing the "gate before the expensive step" principle above — why a platform would deliberately set tighter default quotas on GPU time than CPU time even for teams who haven't requested either yet.