<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ Platform Engineering  - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ Platform Engineering  - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Fri, 17 Jul 2026 22:27:25 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/platform-engineering/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Build an Internal Developer Platform: A Complete Guide to Backstage, ArgoCD, and Crossplane ]]>
                </title>
                <description>
                    <![CDATA[ Every fast-growing engineering team eventually hits the same wall. A developer needs a new staging environment, so they file a ticket. The platform team queues it. Two weeks later, the environment exi ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-an-internal-developer-platform-a-complete-guide-to-backstage-argocd-and-crossplane/</link>
                <guid isPermaLink="false">6a5a912d1a97bb513c72431e</guid>
                
                    <category>
                        <![CDATA[ Platform Engineering  ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Kubernetes ]]>
                    </category>
                
                    <category>
                        <![CDATA[ gitops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Cloud Computing ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Ayobami Adejumo ]]>
                </dc:creator>
                <pubDate>Fri, 17 Jul 2026 20:31:41 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/4e45df2a-5af9-4feb-84fa-f7eb1c04ee91.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Every fast-growing engineering team eventually hits the same wall.</p>
<p>A developer needs a new staging environment, so they file a ticket. The platform team queues it.</p>
<p>Two weeks later, the environment exists. It's configured slightly differently from the last one, with a naming convention that doesn't match the production setup, missing the observability stack the previous environment had. The developer deploys. Something breaks. Nobody knows why.</p>
<p>The problem isn't the ticket queue. The problem is the absence of a platform: a paved road where developers can self-serve infrastructure, deployments, and environments that are consistent, auditable, and safe without requiring a platform engineer for every request.</p>
<p>An Internal Developer Platform (IDP) solves this. Not by removing platform engineers from the picture, but by shifting their work from executing individual requests to building the systems that execute those requests automatically.</p>
<p>This handbook builds a production-grade IDP from the three CNCF tools that form its core in 2026: Backstage as the developer portal and software catalog, ArgoCD as the GitOps continuous delivery engine, and Crossplane as the Kubernetes-native infrastructure control plane.</p>
<p>By the end, developers on your platform will be able to provision a cloud database, deploy an application to staging, and register a new service in the catalog — all without filing a single ticket.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-youll-learn">What You'll Learn</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-part-1-idp-architecture-the-three-layer-model">Part 1: IDP Architecture — The Three-Layer Model</a></p>
</li>
<li><p><a href="#heading-part-2-argocd-the-gitops-foundation">Part 2: ArgoCD — The GitOps Foundation</a></p>
</li>
<li><p><a href="#heading-part-3-crossplane-infrastructure-as-kubernetes-resources">Part 3: Crossplane — Infrastructure as Kubernetes Resources</a></p>
</li>
<li><p><a href="#heading-part-4-backstage-the-developer-portal">Part 4: Backstage — The Developer Portal</a></p>
</li>
<li><p><a href="#heading-part-5-wiring-it-together-the-golden-path">Part 5: Wiring It Together — The Golden Path</a></p>
</li>
<li><p><a href="#heading-part-6-finops-integration-cost-attribution-on-the-idp">Part 6: FinOps Integration — Cost Attribution on the IDP</a></p>
</li>
<li><p><a href="#heading-part-7-the-platform-maturity-model-measuring-what-youve-built">Part 7: The Platform Maturity Model — Measuring What You've Built</a></p>
</li>
<li><p><a href="#heading-best-practices-summary">Best Practices Summary</a></p>
</li>
<li><p><a href="#heading-resources">Resources</a></p>
</li>
</ul>
<h2 id="heading-what-youll-learn">What You'll Learn</h2>
<ul>
<li><p>The three-layer IDP architecture and why each layer must be implemented in a specific order</p>
</li>
<li><p>How to install and configure ArgoCD with ApplicationSets for multi-environment GitOps delivery</p>
</li>
<li><p>How to define cloud infrastructure as Kubernetes custom resources using Crossplane Compositions</p>
</li>
<li><p>How to deploy and configure Backstage with a software catalog and Software Templates</p>
</li>
<li><p>How to wire Backstage, ArgoCD, and Crossplane together into a single self-service golden path</p>
</li>
<li><p>How to implement cost attribution on your IDP so every resource provisioned through it carries team and cost center metadata</p>
</li>
<li><p>How to measure your IDP's maturity using the CNCF Platform Engineering Maturity Model</p>
</li>
</ul>
<p>Let's build it.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before following along, you should have:</p>
<p><strong>Knowledge:</strong></p>
<ul>
<li><p>Working familiarity with Kubernetes: you can deploy applications, write YAML manifests, and understand namespaces and RBAC</p>
</li>
<li><p>Basic GitOps understanding: you know what "Git as source of truth" means in practice</p>
</li>
<li><p>Comfort with Helm, Terraform HCL, and TypeScript at a reading level</p>
</li>
<li><p>Understanding of AWS services: EKS, RDS, S3, IAM</p>
</li>
</ul>
<p><strong>Tools and access:</strong></p>
<ul>
<li><p>An EKS cluster running Kubernetes 1.28 or later with at least 3 nodes (m5.xlarge or equivalent)</p>
</li>
<li><p><code>kubectl</code> configured and pointing at your cluster</p>
</li>
<li><p><code>helm</code> 3.12 or later installed</p>
</li>
<li><p>AWS CLI v2 configured with admin-level permissions for the provisioning steps</p>
</li>
<li><p>Node.js 18 or later and Yarn (for Backstage)</p>
</li>
<li><p>A GitHub organisation you control (for the GitOps repositories and Backstage GitHub integration)</p>
</li>
</ul>
<p><strong>Companion repository:</strong></p>
<pre><code class="language-bash">git clone https://github.com/aayostem/platform-toolkit
cd platform-toolkit
</code></pre>
<p>The repository contains all manifests, Helm values files, Crossplane Compositions, and Backstage templates referenced in this guide. Each part maps to a directory in the repo.</p>
<p><strong>Estimated time:</strong> The full implementation takes one to two days for an experienced platform engineer. Parts 1–3 can be completed in the morning and produce a working GitOps delivery layer.</p>
<h2 id="heading-part-1-idp-architecture-the-three-layer-model">Part 1: IDP Architecture — The Three-Layer Model</h2>
<h3 id="heading-11-what-an-idp-actually-is">1.1 What an IDP Actually Is</h3>
<p>An Internal Developer Platform isn't a tool. It's a product: a collection of tools, workflows, and abstractions that platform teams build and maintain so that application developers can move fast without managing infrastructure directly.</p>
<p>The distinction matters because it shapes every architectural decision. A tool is installed and configured. A product is designed for users, iterated based on feedback, and measured by whether those users actually adopt it. The platform teams that build the IDPs that developers love think like product managers, not system administrators.</p>
<p><a href="https://cloud.google.com/resources/content/2025-dora-ai-capabilities-model-report">The DORA 2025 report</a> found that nearly 90% of enterprises now have some form of internal platform. But having a platform and having a platform that developers actually use are different things.</p>
<p>The survey found that developer satisfaction with internal platforms varied dramatically. And the gap between satisfied and unsatisfied teams correlated directly with whether the platform team treated the IDP as a product with a roadmap and user research, or as an infrastructure project with a ticket queue.</p>
<p>The three tools in this guide — Backstage, ArgoCD, and Crossplane — are the most widely adopted open-source stack for production IDPs in 2026. But the architecture that connects them matters as much as the tools themselves.</p>
<h3 id="heading-12-the-three-layer-architecture">1.2 The Three-Layer Architecture</h3>
<p>A production IDP has three distinct layers, each with a single responsibility:</p>
<pre><code class="language-plaintext">Layer 1: Developer Interface (Backstage)
├── Software catalog — inventory of all services, APIs, and resources
├── Software Templates — self-service forms that trigger provisioning workflows
├── TechDocs — documentation co-located with each catalog entity
└── Plugins — integrations with ArgoCD, Kubernetes, PagerDuty, Grafana

Layer 2: Delivery Layer (ArgoCD)
├── GitOps sync — continuous reconciliation of cluster state to Git
├── ApplicationSets — multi-environment deployment from a single definition
├── Rollout management — progressive delivery with health checks
└── Audit trail — every deployment change linked to a Git commit

Layer 3: Infrastructure Layer (Crossplane)
├── Composite Resources — cloud resources defined as Kubernetes CRDs
├── Compositions — templates that expand a simple claim into full AWS infrastructure
├── ProviderConfigs — credentials and region configuration for each cloud provider
└── Usage tracking — every provisioned resource tagged with team and cost centre
</code></pre>
<p>The critical architectural rule: Backstage never talks directly to Kubernetes or cloud APIs. When a developer submits a Software Template in Backstage, the output is a Git commit — a YAML file representing a Crossplane claim or an ArgoCD Application manifest. ArgoCD picks up that commit and applies it to the cluster. Crossplane translates the cluster resource into actual cloud infrastructure.</p>
<p>This indirect path isn't complexity for complexity's sake. It means every infrastructure change is a Git commit, with an author, a timestamp, a pull request, and a review. The audit trail is automatic. The rollback mechanism is <code>git revert</code>.</p>
<pre><code class="language-plaintext">Developer → Backstage Template → Git commit → ArgoCD → Crossplane → AWS
                                     ↑
                          Single source of truth
                          Full audit trail
                          Rollback = git revert
</code></pre>
<p>Here's what the incorrect alternative looks like — Backstage calling cloud APIs directly:</p>
<pre><code class="language-typescript">// Bad: Backstage template calling AWS SDK directly
// No audit trail, no rollback, no reconciliation loop
// If the call fails halfway, you have partial infrastructure with no record
import { S3Client, CreateBucketCommand } from "@aws-sdk/client-s3";

const client = new S3Client({ region: "us-east-1" });
await client.send(new CreateBucketCommand({ Bucket: bucketName }));
</code></pre>
<p>And the correct approach — Backstage outputting a Crossplane claim to Git:</p>
<pre><code class="language-yaml"># Good: Backstage template output — a Crossplane claim committed to Git
# ArgoCD applies it, Crossplane reconciles it, AWS creates the bucket
# Every step is tracked, auditable, and reversible
apiVersion: platform.cloudfrugal.com/v1alpha1
kind: S3Bucket
metadata:
  name: ${{ values.bucket_name }}
  namespace: ${{ values.team_namespace }}
  labels:
    team: ${{ values.team_name }}
    cost-centre: ${{ values.cost_centre }}
    environment: ${{ values.environment }}
spec:
  versioning: true
  encryption: AES256
  region: us-east-1
</code></pre>
<h3 id="heading-13-implementation-order">1.3 Implementation Order</h3>
<p>Build in this order. Deviating from it creates integration problems that are difficult to debug:</p>
<pre><code class="language-plaintext">Step 1: ArgoCD — the delivery foundation everything else depends on
Step 2: Crossplane — infrastructure control plane, delivered by ArgoCD
Step 3: Backstage — the portal, pointing at ArgoCD and Crossplane as backends
Step 4: Wire together — Software Templates that produce GitOps manifests
Step 5: FinOps layer — cost attribution metadata in every provisioned resource
</code></pre>
<h2 id="heading-part-2-argocd-the-gitops-foundation">Part 2: ArgoCD — The GitOps Foundation</h2>
<p>ArgoCD is a declarative continuous delivery tool for Kubernetes that implements the GitOps pattern. If you haven't used a GitOps tool before, the core idea is simple: your Git repository is the single source of truth for what should be running in your cluster, and ArgoCD continuously reconciles actual cluster state to match it.</p>
<p>If a developer manually changes a resource in the cluster, ArgoCD detects the drift and resyncs from Git. If Git changes, ArgoCD applies the change to the cluster. Human intervention isn't required, and is actively discouraged — the goal is a cluster whose state is always fully explained by what's in Git.</p>
<p>ArgoCD is a CNCF Graduated project, meaning it's production-ready and widely used. It runs as a set of pods in your cluster with a web UI, a CLI, and a REST API. Everything you need to manage deployments across multiple environments lives in one place.</p>
<h3 id="heading-21-installing-argocd">2.1 Installing ArgoCD</h3>
<pre><code class="language-bash"># Create the ArgoCD namespace
kubectl create namespace argocd

# Install ArgoCD using the official manifest
kubectl apply -n argocd \
  -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml

# Wait for all pods to be running before proceeding
kubectl wait --for=condition=Ready pods \
  --all -n argocd --timeout=300s

# Get the initial admin password
argocd_password=$(kubectl -n argocd get secret argocd-initial-admin-secret \
  -o jsonpath="{.data.password}" | base64 -d)

echo "ArgoCD initial password: $argocd_password"
echo "Save this somewhere secure before proceeding"

# Port-forward to access the ArgoCD UI locally
kubectl port-forward svc/argocd-server -n argocd 8080:443 &amp;

# Login via CLI
argocd login localhost:8080 \
  --username admin \
  --password "$argocd_password" \
  --insecure

# Change the password immediately
argocd account update-password \
  --current-password "$argocd_password" \
  --new-password "your-secure-password"
</code></pre>
<h3 id="heading-22-repository-structure-for-gitops">2.2 Repository Structure for GitOps</h3>
<p>The repository structure ArgoCD watches determines how you manage multiple environments. The pattern that scales best is environment-per-directory, with overlays managed by Kustomize.</p>
<p>Kustomize is a Kubernetes-native configuration management tool that lets you define a base configuration once and layer environment-specific overrides on top of it. This means your staging and production configurations share the same YAML structure but differ in replica counts, image tags, and resource limits.</p>
<pre><code class="language-plaintext">gitops-repo/
├── apps/
│   ├── base/                    # Shared configuration across all environments
│   │   ├── payment-api/
│   │   │   ├── deployment.yaml
│   │   │   ├── service.yaml
│   │   │   └── kustomization.yaml
│   │   └── user-api/
│   │       ├── deployment.yaml
│   │       ├── service.yaml
│   │       └── kustomization.yaml
│   └── overlays/
│       ├── staging/             # Staging-specific overrides
│       │   ├── payment-api/
│       │   │   └── kustomization.yaml   # Override: 1 replica, staging image tag
│       │   └── kustomization.yaml
│       └── production/          # Production-specific overrides
│           ├── payment-api/
│           │   └── kustomization.yaml   # Override: 3 replicas, pinned image tag
│           └── kustomization.yaml
└── infrastructure/
    ├── crossplane/              # Crossplane installation and providers
    ├── monitoring/              # Prometheus, Grafana
    └── ingress/                 # NGINX or ALB ingress controller
</code></pre>
<h3 id="heading-23-applicationsets-managing-multiple-environments">2.3 ApplicationSets — Managing Multiple Environments</h3>
<p>An ApplicationSet is an ArgoCD resource that generates multiple Application objects from a single template. Instead of creating one Application manifest per service per environment — which becomes unmanageable at scale — you define one ApplicationSet that covers all services across all environments. A matrix generator combines a list of environments with a Git directory scan to produce every combination automatically:</p>
<pre><code class="language-yaml"># applicationset-apps.yaml
# This single resource generates one ArgoCD Application
# for each combination of environment and application directory
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: platform-apps
  namespace: argocd
spec:
  generators:
    - matrix:
        generators:
          # Generator 1: environments
          - list:
              elements:
                - environment: staging
                  cluster: https://staging.eks.cluster.local
                - environment: production
                  cluster: https://production.eks.cluster.local

          # Generator 2: application directories in the overlay
          - git:
              repoURL: https://github.com/your-org/gitops-repo
              revision: HEAD
              directories:
                - path: apps/overlays/{{environment}}/*

  template:
    metadata:
      name: "{{environment}}-{{path.basename}}"
      labels:
        environment: "{{environment}}"
        app: "{{path.basename}}"
    spec:
      project: default
      source:
        repoURL: https://github.com/your-org/gitops-repo
        targetRevision: HEAD
        path: "apps/overlays/{{environment}}/{{path.basename}}"
      destination:
        server: "{{cluster}}"
        namespace: "{{path.basename}}"
      syncPolicy:
        automated:
          prune: true        # Delete resources removed from Git
          selfHeal: true     # Revert manual cluster changes
        syncOptions:
          - CreateNamespace=true
          - PrunePropagationPolicy=foreground
</code></pre>
<p>Verify the ApplicationSet is generating the expected Applications:</p>
<pre><code class="language-bash"># List all generated Applications
kubectl get applications -n argocd

# Expected output: one Application per environment per app
# staging-payment-api    Synced    Healthy
# staging-user-api       Synced    Healthy
# production-payment-api Synced    Healthy
# production-user-api    Synced    Healthy

# Check sync status for a specific application
argocd app get staging-payment-api
</code></pre>
<h3 id="heading-24-argocd-rbac-for-platform-teams">2.4 ArgoCD RBAC for Platform Teams</h3>
<p>In a multi-team IDP, different teams need different levels of access to ArgoCD. Application teams should be able to view and sync their own applications. Platform teams should have broader access. Nobody should have unrestricted cluster admin through ArgoCD.</p>
<p>The default policy is <code>readonly</code> — every authenticated user can see everything but change nothing:</p>
<pre><code class="language-yaml"># argocd-rbac-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: argocd-rbac-cm
  namespace: argocd
data:
  policy.default: role:readonly
  policy.csv: |
    # Platform team: full access to all applications
    p, role:platform-team, applications, *, */*, allow
    p, role:platform-team, clusters, get, *, allow
    p, role:platform-team, repositories, *, *, allow

    # Application teams: sync and get their own namespace only
    p, role:app-team, applications, get, */staging-*, allow
    p, role:app-team, applications, sync, */staging-*, allow

    # Bind roles to GitHub teams
    g, your-org:platform-engineers, role:platform-team
    g, your-org:developers, role:app-team

  scopes: '[groups]'
</code></pre>
<h2 id="heading-part-3-crossplane-infrastructure-as-kubernetes-resources">Part 3: Crossplane — Infrastructure as Kubernetes Resources</h2>
<p>Crossplane is a CNCF Graduated open-source framework that extends Kubernetes into a universal infrastructure control plane.</p>
<p>The core idea: instead of managing cloud resources with separate tools like Terraform or CloudFormation that live outside your cluster, you define cloud resources — RDS databases, S3 buckets, VPCs, IAM roles — as Kubernetes custom resource definitions.</p>
<p>Once you apply a Crossplane resource to the cluster, Crossplane's controllers take over and reconcile the desired state to the actual AWS state, exactly the way Kubernetes reconciles a Deployment to a set of running pods.</p>
<p>The key abstraction Crossplane adds on top of that is the Composite Resource. A platform team defines a high-level <code>PostgreSQLDatabase</code> type that abstracts over the thirty-plus configuration fields an actual RDS instance requires.</p>
<p>Developers interact with the simple type. Crossplane expands it into the full AWS resource configuration behind the scenes, applying the platform team's security and operational standards automatically — standards that developers can't bypass because they never see the underlying fields.</p>
<h3 id="heading-31-installing-crossplane">3.1 Installing Crossplane</h3>
<p>Crossplane is delivered to your cluster by ArgoCD — the first integration between the two tools. By installing Crossplane through an ArgoCD Application rather than running <code>helm install</code> directly, you make Crossplane itself part of the GitOps-managed infrastructure. Any change to Crossplane's configuration goes through a Git commit and review:</p>
<pre><code class="language-yaml"># infrastructure/crossplane/application.yaml
# ArgoCD Application that installs Crossplane via Helm
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: crossplane
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://charts.crossplane.io/stable
    chart: crossplane
    targetRevision: 1.15.0
    helm:
      values: |
        provider:
          packages:
            # AWS provider — manages all AWS resources
            - xpkg.upbound.io/upbound/provider-aws-s3:v1.2.0
            - xpkg.upbound.io/upbound/provider-aws-rds:v1.2.0
            - xpkg.upbound.io/upbound/provider-aws-iam:v1.2.0
  destination:
    server: https://kubernetes.default.svc
    namespace: crossplane-system
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
      - CreateNamespace=true
</code></pre>
<pre><code class="language-bash"># Apply the ArgoCD Application — ArgoCD installs Crossplane
kubectl apply -f infrastructure/crossplane/application.yaml

# Watch Crossplane pods come up
kubectl get pods -n crossplane-system -w

# Verify providers are installed and healthy
kubectl get providers
# Expected:
# NAME                          INSTALLED   HEALTHY   PACKAGE
# upbound-provider-aws-s3       True        True      xpkg.upbound.io/...
# upbound-provider-aws-rds      True        True      xpkg.upbound.io/...
</code></pre>
<h3 id="heading-32-provider-credentials">3.2 Provider Credentials</h3>
<p>Crossplane needs AWS credentials to provision resources. The recommended approach for EKS is IAM Roles for Service Accounts (IRSA) — a mechanism that lets Kubernetes pods assume IAM roles directly without storing any credentials in the cluster.</p>
<p>The pod's Kubernetes service account is annotated with an IAM role ARN, and AWS automatically provides short-lived credentials when the pod makes API calls. No access keys, no secrets to rotate, and no credentials to accidentally expose:</p>
<pre><code class="language-bash"># Create the IAM role for Crossplane with the necessary AWS permissions
aws iam create-role \
  --role-name CrossplaneProviderRole \
  --assume-role-policy-document '{
    "Version": "2012-10-17",
    "Statement": [{
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::YOUR_ACCOUNT_ID:oidc-provider/oidc.eks.us-east-1.amazonaws.com/id/YOUR_OIDC_ID"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "oidc.eks.us-east-1.amazonaws.com/id/YOUR_OIDC_ID:sub":
            "system:serviceaccount:crossplane-system:provider-aws"
        }
      }
    }]
  }'

# Attach the permissions policy (scope this to minimum required in production)
aws iam attach-role-policy \
  --role-name CrossplaneProviderRole \
  --policy-arn arn:aws:iam::aws:policy/AdministratorAccess
</code></pre>
<pre><code class="language-yaml"># provider-config.yaml
# Configure the AWS provider with IRSA — no static credentials
apiVersion: aws.upbound.io/v1beta1
kind: ProviderConfig
metadata:
  name: default
spec:
  credentials:
    source: IRSA   # Use the IAM role attached to the provider service account
</code></pre>
<h3 id="heading-33-defining-a-composite-resource-postgresql-database">3.3 Defining a Composite Resource — PostgreSQL Database</h3>
<p>This is where the IDP abstraction lives. The platform team defines two YAML files: the <code>CompositeResourceDefinition</code> (XRD), which specifies the shape of what developers can request, and the <code>Composition</code>, which specifies how that request expands into actual AWS resources with platform standards applied.</p>
<p>The XRD is the API contract with developers. Keep it simple — only fields developers genuinely need to control should appear here:</p>
<pre><code class="language-yaml"># xrd-postgresql.yaml
# Defines the PostgreSQLDatabase type that developers can request
# Developers never see the RDS-specific configuration below
apiVersion: apiextensions.crossplane.io/v1
kind: CompositeResourceDefinition
metadata:
  name: xpostgresqldatabases.platform.cloudfrugal.com
spec:
  group: platform.cloudfrugal.com
  names:
    kind: XPostgreSQLDatabase
    plural: xpostgresqldatabases
  claimNames:
    kind: PostgreSQLDatabase     # This is what developers create
    plural: postgresqldatabases
  versions:
    - name: v1alpha1
      served: true
      referenceable: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                # Developer-facing fields only — simple and bounded
                storageGB:
                  type: integer
                  minimum: 20
                  maximum: 1000
                  description: "Storage in GB. Min 20, max 1000."
                instanceClass:
                  type: string
                  enum: ["small", "medium", "large"]
                  description: "small=db.t4g.medium, medium=db.r7g.large, large=db.r7g.2xlarge"
                environment:
                  type: string
                  enum: ["staging", "production"]
</code></pre>
<p>The Composition is the platform team's implementation. It maps the simple developer fields to the full RDS configuration and enforces platform standards that developers can't override:</p>
<pre><code class="language-yaml"># composition-postgresql.yaml
# Defines what a PostgreSQLDatabase claim expands into
# Platform standards (encryption, backup, deletion protection) are applied here
# Developers cannot override them — the platform enforces them
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
  name: postgresql-aws-composition
  labels:
    provider: aws
spec:
  compositeTypeRef:
    apiVersion: platform.cloudfrugal.com/v1alpha1
    kind: XPostgreSQLDatabase

  resources:
    # The actual RDS instance — expanded from the simple developer claim
    - name: rds-instance
      base:
        apiVersion: rds.aws.upbound.io/v1beta1
        kind: Instance
        spec:
          forProvider:
            region: us-east-1
            engine: postgres
            engineVersion: "15.4"
            # Platform standards — always applied, not developer-configurable
            storageEncrypted: true           # Always encrypted
            backupRetentionPeriod: 7         # Always 7-day backup
            deletionProtection: true         # Always deletion-protected
            multiAZ: false                   # Overridden to true for production (see patches)
            dbSubnetGroupNameSelector:
              matchLabels:
                platform.cloudfrugal.com/subnet-group: private
      patches:
        # Map the developer's simple instanceClass to the actual RDS instance type
        - type: CombineFromComposite
          combine:
            variables:
              - fromFieldPath: spec.instanceClass
            strategy: string
            string:
              fmt: |
                %s
          toFieldPath: spec.forProvider.dbInstanceClass
          transforms:
            - type: map
              map:
                small:  db.t4g.medium
                medium: db.r7g.large
                large:  db.r7g.2xlarge

        # Enable Multi-AZ for production automatically
        - type: FromCompositeFieldPath
          fromFieldPath: spec.environment
          toFieldPath: spec.forProvider.multiAZ
          transforms:
            - type: map
              map:
                staging:    "false"
                production: "true"

        # Copy team labels from the claim to the RDS instance for cost attribution
        - type: FromCompositeFieldPath
          fromFieldPath: metadata.labels
          toFieldPath: spec.forProvider.tags
</code></pre>
<p>A developer requesting a PostgreSQL database now writes this — nothing more:</p>
<pre><code class="language-yaml"># Developer creates this in their team's namespace
# No RDS knowledge required. No IAM configuration. No subnet group lookup.
apiVersion: platform.cloudfrugal.com/v1alpha1
kind: PostgreSQLDatabase
metadata:
  name: payment-service-db
  namespace: payments-team
  labels:
    team: payments
    cost-centre: payments-engineering
    environment: staging
spec:
  storageGB: 100
  instanceClass: medium
  environment: staging
</code></pre>
<p>Crossplane reconciles this claim to a full RDS instance within minutes, with encryption, backup, and all platform standards applied automatically.</p>
<h3 id="heading-34-verifying-crossplane-resource-provisioning">3.4 Verifying Crossplane Resource Provisioning</h3>
<pre><code class="language-bash"># Watch the claim status — it should transition to Ready=True
kubectl get postgresqldatabases -n payments-team -w

# Check the composite resource for detailed status
kubectl describe xpostgresqldatabases.platform.cloudfrugal.com

# Verify the actual AWS resource was created
aws rds describe-db-instances \
  --query 'DBInstances[?TagList[?Key==`team` &amp;&amp; Value==`payments`]].[DBInstanceIdentifier,DBInstanceStatus]' \
  --output table
</code></pre>
<h2 id="heading-part-4-backstage-the-developer-portal">Part 4: Backstage — The Developer Portal</h2>
<p>Backstage is a CNCF incubating open-source framework originally built by Spotify. It serves as the developer-facing interface of your IDP — the single place where developers discover services, request infrastructure, and find documentation, without needing to know which underlying system provides any of it.</p>
<p>Backstage provides three core capabilities:</p>
<ol>
<li><p>A software catalog that inventories every service, API, library, and resource in your organisation</p>
</li>
<li><p>Software Templates that give developers self-service forms for provisioning infrastructure and scaffolding new services</p>
</li>
<li><p>TechDocs that co-locate documentation with the catalog entity it documents so that documentation is always findable from the same place as the service it covers.</p>
</li>
</ol>
<p>Backstage is built in TypeScript with a React frontend and a Node.js backend. It's configured rather than installed: you create a Backstage app, configure it with your organisation's specifics, and deploy it to your cluster.</p>
<h3 id="heading-41-creating-and-configuring-backstage">4.1 Creating and Configuring Backstage</h3>
<pre><code class="language-bash"># Create a new Backstage app
npx @backstage/create-app@latest

# When prompted:
# App name: platform-portal
# Choose SQLite for local development, PostgreSQL for production

cd platform-portal
</code></pre>
<p>Configure Backstage to connect to your ArgoCD instance and GitHub:</p>
<pre><code class="language-yaml"># app-config.production.yaml
app:
  title: Cloudfrugal Platform Portal
  baseUrl: https://platform.your-company.com

backend:
  baseUrl: https://platform.your-company.com
  database:
    client: pg
    connection:
      host: ${POSTGRES_HOST}
      port: 5432
      user: ${POSTGRES_USER}
      password: ${POSTGRES_PASSWORD}
      database: backstage

# GitHub integration for catalog discovery and template scaffolding
integrations:
  github:
    - host: github.com
      apps:
        - appId: ${GITHUB_APP_ID}
          webhookSecret: ${GITHUB_WEBHOOK_SECRET}
          clientId: ${GITHUB_CLIENT_ID}
          clientSecret: ${GITHUB_CLIENT_SECRET}
          privateKey: ${GITHUB_PRIVATE_KEY}

# ArgoCD plugin configuration
argocd:
  username: ${ARGOCD_USERNAME}
  password: ${ARGOCD_PASSWORD}
  appLocatorMethods:
    - type: 'config'
      instances:
        - name: main
          url: https://argocd.your-company.com

# Catalog auto-discovery — finds catalog-info.yaml files across your GitHub org
catalog:
  providers:
    github:
      your-org:
        organization: 'your-github-org'
        catalogPath: '/catalog-info.yaml'
        filters:
          branch: 'main'
</code></pre>
<h3 id="heading-42-the-software-catalog-registering-services">4.2 The Software Catalog — Registering Services</h3>
<p>Every service, API, library, and resource in your platform should be registered in the Backstage catalog via a <code>catalog-info.yaml</code> file committed to the service's repository. Backstage discovers these files automatically through the GitHub integration — no manual registration required once the file exists:</p>
<pre><code class="language-yaml"># catalog-info.yaml — committed to each service's repository root
# Backstage discovers this automatically via the GitHub integration
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
  name: payment-api
  title: Payment API
  description: "Core payment processing service. Handles transaction initiation, authorisation, and settlement."
  annotations:
    # Links ArgoCD to show deployment status in the Backstage UI
    argocd/app-name: production-payment-api
    # Links GitHub Actions workflow status
    github.com/project-slug: your-org/payment-api
    # Links Grafana dashboard for this service
    grafana/dashboard-selector: "title=Payment API"
    # Links PagerDuty on-call schedule
    pagerduty.com/service-id: P123456
  tags:
    - payments
    - typescript
    - critical
  links:
    - url: https://payment-api.docs.your-company.com
      title: Documentation
    - url: https://grafana.your-company.com/d/payment-api
      title: Grafana Dashboard
spec:
  type: service
  lifecycle: production
  owner: group:payments-team
  system: payment-platform
  dependsOn:
    - component:user-api
    - resource:payment-service-db
  providesApis:
    - payment-api-v2
</code></pre>
<h3 id="heading-43-software-templates-self-service-infrastructure">4.3 Software Templates — Self-Service Infrastructure</h3>
<p>A Software Template is a Backstage form that, when submitted, produces a Git commit. The commit contains whatever YAML, code, or configuration the template defines.</p>
<p>For infrastructure provisioning, the output is a Crossplane claim. For new service scaffolding, the output is a complete service skeleton committed to a new repository.</p>
<p>The key design decision: templates should create pull requests, not merge directly. The PR gives platform teams visibility, gives developers a review moment, and gives everyone an audit trail. Auto-merge policies can eliminate the review step for low-risk provisioning once you've built trust in the template's outputs:</p>
<pre><code class="language-yaml"># templates/postgresql-database/template.yaml
# This template gives developers a form to request a PostgreSQL database
# The output is a Crossplane PostgreSQLDatabase claim committed to the GitOps repo
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
  name: postgresql-database
  title: PostgreSQL Database
  description: Provision a managed PostgreSQL database on AWS RDS. Encryption, backups, and deletion protection are configured automatically by the platform.
  tags:
    - database
    - postgresql
    - aws
spec:
  owner: group:platform-team
  type: infrastructure

  # The form developers fill out in the Backstage UI
  parameters:
    - title: Database Configuration
      required: [name, team, environment, storageGB, instanceClass]
      properties:
        name:
          title: Database Name
          type: string
          description: "Lowercase, hyphens only. E.g. payment-service-db"
          pattern: '^[a-z][a-z0-9-]*$'

        team:
          title: Owning Team
          type: string
          description: "Your team name. Used for cost attribution and ownership."
          ui:field: OwnerPicker
          ui:options:
            catalogFilter:
              kind: Group

        environment:
          title: Environment
          type: string
          enum: [staging, production]
          default: staging

        storageGB:
          title: Storage (GB)
          type: integer
          minimum: 20
          maximum: 1000
          default: 50

        instanceClass:
          title: Instance Size
          type: string
          enum: [small, medium, large]
          enumNames:
            - "Small (db.t4g.medium) — dev/staging workloads"
            - "Medium (db.r7g.large) — moderate production traffic"
            - "Large (db.r7g.2xlarge) — high-throughput production"
          default: small

  # What the template does when submitted
  steps:
    - id: generate-claim
      name: Generate Crossplane Claim
      action: fetch:template
      input:
        url: ./skeleton    # Contains the Crossplane claim YAML template
        values:
          name: ${{ parameters.name }}
          team: ${{ parameters.team | parseEntityRef | pick('name') }}
          environment: ${{ parameters.environment }}
          storageGB: ${{ parameters.storageGB }}
          instanceClass: ${{ parameters.instanceClass }}

    - id: create-pr
      name: Create Pull Request to GitOps Repo
      action: publish:github:pull-request
      input:
        repoUrl: github.com?repo=gitops-repo&amp;owner=your-org
        title: "Platform: Provision PostgreSQL database ${{ parameters.name }} for ${{ parameters.team }}"
        branchName: "provision-db-${{ parameters.name }}-${{ '' | now }}"
        description: |
          Requesting PostgreSQL database provisioned by Crossplane.

          - **Name:** ${{ parameters.name }}
          - **Team:** ${{ parameters.team }}
          - **Environment:** ${{ parameters.environment }}
          - **Storage:** ${{ parameters.storageGB }}GB
          - **Instance:** ${{ parameters.instanceClass }}

          Approve this PR to trigger provisioning. ArgoCD will pick up the change and Crossplane will create the RDS instance within ~5 minutes of merge.
        sourcePath: ./skeleton

  output:
    links:
      - title: View Pull Request
        url: ${{ steps['create-pr'].output.remoteUrl }}
      - title: Track Provisioning in ArgoCD
        url: https://argocd.your-company.com/applications
</code></pre>
<p>The template skeleton directory contains the Crossplane claim with template variable placeholders:</p>
<pre><code class="language-yaml"># templates/postgresql-database/skeleton/databases/${{ values.name }}.yaml
apiVersion: platform.cloudfrugal.com/v1alpha1
kind: PostgreSQLDatabase
metadata:
  name: ${{ values.name }}
  namespace: ${{ values.team }}-platform
  labels:
    team: ${{ values.team }}
    cost-centre: ${{ values.team }}-engineering
    environment: ${{ values.environment }}
    managed-by: backstage-scaffolder
spec:
  storageGB: ${{ values.storageGB }}
  instanceClass: ${{ values.instanceClass }}
  environment: ${{ values.environment }}
</code></pre>
<h2 id="heading-part-5-wiring-it-together-the-golden-path">Part 5: Wiring It Together — The Golden Path</h2>
<p>The Golden Path is the complete end-to-end workflow: a developer uses Backstage to request infrastructure, that request becomes a Git commit, ArgoCD applies the commit to the cluster, Crossplane provisions the actual AWS resource, and the result appears in both the Backstage catalog and the ArgoCD dashboard.</p>
<h3 id="heading-51-the-complete-flow">5.1 The Complete Flow</h3>
<pre><code class="language-plaintext">Developer fills form in Backstage
    ↓
Backstage Software Template renders the Crossplane claim YAML
    ↓
Backstage creates a Pull Request in the GitOps repository
    ↓
Platform engineer (or auto-merge policy) approves and merges the PR
    ↓
ArgoCD detects the new file in the GitOps repository
    ↓
ArgoCD applies the Crossplane claim to the cluster
    ↓
Crossplane reconciles the claim to an actual AWS RDS instance
    ↓
Developer receives the database endpoint via Kubernetes Secret
    ↓
Backstage catalog shows the new resource, owned by the requesting team
</code></pre>
<h3 id="heading-52-surfacing-resource-status-back-in-backstage">5.2 Surfacing Resource Status Back in Backstage</h3>
<p>The Backstage Kubernetes plugin pulls live pod and resource status from your clusters and displays it on each catalog entity page. Developers can see whether their service is running, how many replicas are healthy, and whether the last deployment synced — without leaving Backstage or learning <code>kubectl</code>:</p>
<pre><code class="language-bash"># Install the Kubernetes plugin packages
cd platform-portal
yarn --cwd packages/app add @backstage/plugin-kubernetes
yarn --cwd packages/backend add @backstage/plugin-kubernetes-backend
</code></pre>
<pre><code class="language-yaml"># app-config.production.yaml — add Kubernetes cluster configuration
kubernetes:
  serviceLocatorMethod:
    type: 'multiTenant'
  clusterLocatorMethods:
    - type: 'config'
      clusters:
        - name: production-eks
          url: ${PRODUCTION_CLUSTER_URL}
          authProvider: serviceAccount
          serviceAccountToken: ${PRODUCTION_SA_TOKEN}
          caData: ${PRODUCTION_CA_DATA}
        - name: staging-eks
          url: ${STAGING_CLUSTER_URL}
          authProvider: serviceAccount
          serviceAccountToken: ${STAGING_SA_TOKEN}
          caData: ${STAGING_CA_DATA}
</code></pre>
<p>Annotate each catalog entity to link it to its Kubernetes resources:</p>
<pre><code class="language-yaml"># In each service's catalog-info.yaml
annotations:
  backstage.io/kubernetes-label-selector: 'app=payment-api'
  backstage.io/kubernetes-namespace: payments-team
</code></pre>
<h3 id="heading-53-installing-the-argocd-plugin">5.3 Installing the ArgoCD Plugin</h3>
<p>The ArgoCD plugin shows deployment history and sync status directly in the Backstage entity page. When a developer opens the payment-api page in the catalog, they can see the last 10 deployments, the current sync state, and whether the application is healthy — all without opening the ArgoCD UI:</p>
<pre><code class="language-bash">yarn --cwd packages/app add @roadiehq/backstage-plugin-argo-cd
</code></pre>
<pre><code class="language-typescript">// packages/app/src/components/catalog/EntityPage.tsx
import { EntityArgoCDOverviewCard } from '@roadiehq/backstage-plugin-argo-cd';

// Add to the service entity page layout
const serviceEntityPage = (
  &lt;EntityLayout&gt;
    &lt;EntityLayout.Route path="/" title="Overview"&gt;
      &lt;Grid container spacing={3}&gt;
        &lt;Grid item md={6}&gt;
          &lt;EntityAboutCard variant="gridItem" /&gt;
        &lt;/Grid&gt;
        &lt;Grid item md={6}&gt;
          {/* ArgoCD deployment status — shows sync state and recent deployments */}
          &lt;EntityArgoCDOverviewCard /&gt;
        &lt;/Grid&gt;
      &lt;/Grid&gt;
    &lt;/EntityLayout.Route&gt;
  &lt;/EntityLayout&gt;
);
</code></pre>
<h2 id="heading-part-6-finops-integration-cost-attribution-on-the-idp">Part 6: FinOps Integration — Cost Attribution on the IDP</h2>
<p>An IDP that provisions resources without cost attribution creates a new problem: you now have automated infrastructure provisioning with no clear ownership of the bill it generates. Every resource created through the IDP must carry team and cost centre metadata from the moment it's provisioned.</p>
<h3 id="heading-61-mandatory-labels-on-every-crossplane-composition">6.1 Mandatory Labels on Every Crossplane Composition</h3>
<p>The Crossplane Compositions are where cost attribution is enforced — not in the developer-facing claim, but in the platform layer that the developer can't bypass. These labels flow through to the actual AWS resource as tags, which means they appear in AWS Cost Explorer and can be used to build team-level cost reports:</p>
<pre><code class="language-yaml"># In every Composition, add mandatory cost attribution patches
patches:
  # These labels flow to the actual AWS resource as tags
  # They can't be omitted or overridden by the developer claim
  - type: FromCompositeFieldPath
    fromFieldPath: metadata.labels[team]
    toFieldPath: spec.forProvider.tags[team]

  - type: FromCompositeFieldPath
    fromFieldPath: metadata.labels[cost-centre]
    toFieldPath: spec.forProvider.tags[cost-centre]

  - type: FromCompositeFieldPath
    fromFieldPath: metadata.labels[environment]
    toFieldPath: spec.forProvider.tags[environment]

  # Add a managed-by tag to identify all IDP-provisioned resources
  - type: FromCompositeFieldPath
    fromFieldPath: metadata.name
    toFieldPath: spec.forProvider.tags[managed-by]
    transforms:
      - type: string
        string:
          fmt: "idp-crossplane"
</code></pre>
<h3 id="heading-62-cost-attribution-query">6.2 Cost Attribution Query</h3>
<p>With mandatory tags on every resource, you can query actual cost by team directly from AWS Cost Explorer:</p>
<pre><code class="language-bash"># Monthly cost breakdown by team — all IDP-provisioned resources
aws ce get-cost-and-usage \
  --time-period Start=$(date -d 'last month' +%Y-%m-01),End=$(date +%Y-%m-01) \
  --granularity MONTHLY \
  --filter '{
    "Tags": {
      "Key": "managed-by",
      "Values": ["idp-crossplane"]
    }
  }' \
  --group-by Type=TAG,Key=team \
  --metrics UnblendedCost \
  --query 'ResultsByTime[0].Groups[*].{Team:Keys[0],Cost:Metrics.UnblendedCost.Amount}' \
  --output table
</code></pre>
<p>Every team that provisions resources through the IDP now has a line on the cost report with their name on it. This is the chargeback model that makes FinOps sustainable at platform scale — attribution is automatic, not manual.</p>
<h2 id="heading-part-7-the-platform-maturity-model-measuring-what-youve-built">Part 7: The Platform Maturity Model — Measuring What You've Built</h2>
<p>The CNCF Platform Engineering Maturity Model defines five levels of platform maturity. Knowing where you sit helps you decide what to build next and communicate progress to engineering leadership.</p>
<table>
<thead>
<tr>
<th>Level</th>
<th>Name</th>
<th>Characteristics</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>Provisional</td>
<td>Ad hoc scripts, manual provisioning, no standard tools</td>
</tr>
<tr>
<td>2</td>
<td>Operational</td>
<td>Standardised tools, some automation, Kubernetes in use</td>
</tr>
<tr>
<td>3</td>
<td>Scalable</td>
<td>Self-service portal, GitOps delivery, documented golden paths</td>
</tr>
<tr>
<td>4</td>
<td>Optimising</td>
<td>Cost attribution, SLOs on the platform itself, user feedback loops</td>
</tr>
<tr>
<td>5</td>
<td>Optimised</td>
<td>AI-assisted provisioning, predictive scaling, full FinOps integration</td>
</tr>
</tbody></table>
<p>A complete Backstage + ArgoCD + Crossplane implementation, with cost attribution and Software Templates covering your most common developer requests, puts you at Level 3. Moving to Level 4 requires adding SLO alerting on the platform's own health, running quarterly developer experience surveys, and producing a monthly cost-by-team report from the attribution tags.</p>
<p>The most common mistake at Level 3: building more features instead of measuring adoption. A platform that has 12 Software Templates but only 2 are regularly used hasn't reached Level 3 — it's reached Level 2 with more YAML. Measure which golden paths are used, interview developers who aren't using the portal, and fix the friction before adding capabilities.</p>
<h2 id="heading-best-practices-summary">Best Practices Summary</h2>
<p>✅ <strong>Do:</strong> Build in order — ArgoCD first, then Crossplane, then Backstage. Each layer depends on the previous one.</p>
<p>✅ <strong>Do:</strong> Use Backstage as a Git commit generator, not as an infrastructure caller. All infrastructure changes must be auditable Git commits.</p>
<p>✅ <strong>Do:</strong> Apply cost attribution tags in the Crossplane Composition layer, not in the developer claim. Attribution that developers can bypass will be bypassed.</p>
<p>✅ <strong>Do:</strong> Start with two or three Software Templates and make them excellent before building more. Template adoption is your most important early metric.</p>
<p>✅ <strong>Do:</strong> Register every service in the Backstage catalog from day one. The catalog's value is proportional to its coverage.</p>
<p>✅ <strong>Do:</strong> Deliver Crossplane to your cluster via ArgoCD, not <code>helm install</code>. Everything the IDP manages should itself be managed by the IDP.</p>
<p>❌ <strong>Don't:</strong> Connect Backstage directly to cloud APIs. No audit trail, no rollback, no reconciliation.</p>
<p>❌ <strong>Don't:</strong> Give developers the Crossplane XRD directly. The Composition abstraction exists to hide RDS-specific configuration and enforce platform standards. Bypassing it defeats the purpose.</p>
<p>❌ <strong>Don't:</strong> Build the IDP in isolation and announce it as done. Platform engineering is product engineering. Schedule user interviews after the first two templates are live.</p>
<p>❌ <strong>Don't:</strong> Skip the ArgoCD RBAC configuration. An IDP that gives all developers cluster-admin through the delivery layer has created a security problem larger than the one it solved.</p>
<h2 id="heading-resources">Resources</h2>
<ul>
<li><p><a href="https://backstage.io/docs"><strong>Backstage Documentation</strong></a> — Official reference for plugin development, Software Templates, and catalog configuration</p>
</li>
<li><p><a href="https://docs.crossplane.io"><strong>Crossplane Documentation</strong></a> — CompositeResourceDefinition and Composition reference, provider installation guides</p>
</li>
<li><p><a href="https://argo-cd.readthedocs.io"><strong>ArgoCD Documentation</strong></a> — ApplicationSet generator reference, RBAC configuration, and sync policy options</p>
</li>
<li><p><a href="https://tag-app-delivery.cncf.io/whitepapers/platform-eng-maturity-model/"><strong>CNCF Platform Engineering Maturity Model</strong></a> — The maturity framework referenced in Part 7</p>
</li>
<li><p><a href="https://marketplace.upbound.io/providers/upbound/provider-aws"><strong>AWS Provider for Crossplane</strong></a> — Complete reference for all AWS resource types available through Crossplane</p>
</li>
<li><p><a href="https://backstage.io/docs/features/kubernetes/"><strong>Backstage Kubernetes Plugin</strong></a> — Setup guide for the Kubernetes resource visibility integration in Part 5</p>
</li>
<li><p><a href="https://www.finops.org/framework/capabilities/"><strong>FinOps Foundation — FinOps for Platform Engineering</strong></a> — Framework reference for the cost attribution model in Part 6</p>
</li>
<li><p><a href="https://github.com/aayostem/platform-toolkit"><strong>Companion Repository</strong></a> — All manifests, Compositions, ApplicationSets, and Backstage templates from this guide</p>
</li>
<li><p><a href="https://cloud.google.com/resources/content/2025-dora-ai-capabilities-model-report"><strong>2025 DORA State of AI-assisted Software Development Report</strong></a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an End-to-End ML Platform Locally: From Experiment Tracking to CI/CD ]]>
                </title>
                <description>
                    <![CDATA[ Machine learning projects don’t end at training a model in a Jupyter notebook. The hard part is the “last mile”: turning that notebook model into something you can run reliably, update safely, and tru ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-end-to-end-ml-platform-locally-from-experiment-tracking-to-cicd/</link>
                <guid isPermaLink="false">69b9bab4c22d3eeb8afd5284</guid>
                
                    <category>
                        <![CDATA[ Machine Learning ]]>
                    </category>
                
                    <category>
                        <![CDATA[ mlops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Platform Engineering  ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Data Science ]]>
                    </category>
                
                    <category>
                        <![CDATA[ FastAPI ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Sandeep Bharadwaj Mannapur ]]>
                </dc:creator>
                <pubDate>Tue, 17 Mar 2026 20:33:56 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/8401d978-0bed-4534-af93-f6bfc1b77c89.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Machine learning projects don’t end at training a model in a Jupyter notebook. The hard part is the “last mile”: turning that notebook model into something you can run reliably, update safely, and trust over time.</p>
<p>Most ML systems fail in production for boring (and painful) reasons: the training code and the serving code drift apart, input data changes shape, a “small” preprocessing tweak breaks predictions, or the model silently degrades because real-world behavior shifts. None of these problems are solved by a better algorithm, they’re solved by engineering: repeatable pipelines, validation, versioning, monitoring, and automated checks.</p>
<p>In this hands-on handbook, you’ll build a complete mini ML platform on your local machine, an end-to-end project that takes a model from training to deployment with the core “last mile” infrastructure in place. We’ll use a fraud detection example (predicting fraudulent transactions), but the same workflow works for churn prediction or any binary classification problem. Everything runs locally (no cloud required), and every step is copy-paste runnable so you can follow along and verify outputs as you go.</p>
<p>By the end, you'll have a production-ready ML pipeline running on your machine – from training the model to serving predictions, with the infrastructure to test, monitor, and iterate with confidence. And yes, we'll do it in a hands-on manner with code snippets you can copy-paste and run. Let's dive in!</p>
<p>📦 <strong>Get the Complete Code</strong><br>All code from this handbook is available in a ready-to-run repository:<br><strong>Repository:</strong> <a href="https://github.com/sandeepmb/freecodecamp-local-ml-platform">https://github.com/sandeepmb/freecodecamp-local-ml-platform</a><br>Clone it and follow along, or use it as a reference implementation.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-project-overview-and-setup">Project Overview and Setup</a></p>
</li>
<li><p><a href="#heading-1-build-a-simple-model-and-api-the-naive-approach">Build a Simple Model and API (The Naive Approach)</a></p>
<ul>
<li><p><a href="#heading-11-train-a-quick-model">Train a Quick Model</a></p>
</li>
<li><p><a href="#heading-12-serve-predictions-with-fastapi">Serve Predictions with FastAPI</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-2-where-the-naive-approach-breaks">Where the Naive Approach Breaks</a></p>
<ul>
<li><p><a href="#heading-problem-1-no-experiment-tracking-reproducibility">Problem 1: No Experiment Tracking (Reproducibility)</a></p>
</li>
<li><p><a href="#heading-problem-2-model-versioning-and-deployment-chaos">Problem 2: Model Versioning and Deployment Chaos</a></p>
</li>
<li><p><a href="#heading-problem-3-no-data-validation-garbage-in-garbage-out">Problem 3: No Data Validation – Garbage In, Garbage Out</a></p>
</li>
<li><p><a href="#heading-problem-4-model-drift-performance-decay-over-time">Problem 4: Model Drift – Performance Decay Over Time</a></p>
</li>
<li><p><a href="#heading-problem-5-no-ci-cd-or-deployment-safety">Problem 5: No CI/CD or Deployment Safety</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-3-add-experiment-tracking-and-model-registry-with-mlflow">Add Experiment Tracking and Model Registry with MLflow</a></p>
<ul>
<li><p><a href="#heading-31-how-to-set-up-the-mlflow-tracking-server">How to Set Up the MLflow Tracking Server</a></p>
</li>
<li><p><a href="#heading-32-how-to-log-experiments-in-code">How to Log Experiments in Code</a></p>
</li>
<li><p><a href="#heading-33-how-to-use-the-model-registry">How to Use the Model Registry</a></p>
</li>
<li><p><a href="#heading-34-update-api-to-load-from-registry">Update API to Load from Registry</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-4-ensure-feature-consistency-with-feast">Ensure Feature Consistency with Feast</a></p>
<ul>
<li><p><a href="#heading-41-what-is-feast-and-why-use-it">What Is Feast and Why Use It?</a></p>
</li>
<li><p><a href="#heading-42-install-and-initialize-feast">Install and Initialize Feast</a></p>
</li>
<li><p><a href="#heading-43-define-feature-definitions">Define Feature Definitions</a></p>
</li>
<li><p><a href="#heading-44-materialize-features-to-the-online-store">Materialize Features to the Online Store</a></p>
</li>
<li><p><a href="#heading-45-retrieve-features-for-training-and-serving">Retrieve Features for Training and Serving</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-5-add-data-validation-with-great-expectations">Add Data Validation with Great Expectations</a></p>
<ul>
<li><p><a href="#heading-51-define-expectations">Define Expectations</a></p>
</li>
<li><p><a href="#heading-52-integrate-validation-into-fastapi">Integrate Validation into FastAPI</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-6-monitor-model-performance-and-data-drift">Monitor Model Performance and Data Drift</a></p>
<ul>
<li><p><a href="#heading-61-the-four-pillars-of-ml-observability">The Four Pillars of ML Observability</a></p>
</li>
<li><p><a href="#heading-62-build-a-drift-monitor-with-evidently">Build a Drift Monitor with Evidently</a></p>
</li>
<li><p><a href="#heading-63-production-monitoring-strategy">Production Monitoring Strategy</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-7-automate-testing-and-deployment-with-ci-cd">Automate Testing and Deployment with CI/CD</a></p>
<ul>
<li><p><a href="#heading-71-write-tests-for-data-and-model">Write Tests for Data and Model</a></p>
</li>
<li><p><a href="#heading-72-github-actions-workflow">GitHub Actions Workflow</a></p>
</li>
<li><p><a href="#heading-73-dockerize-the-application">Dockerize the Application</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-8-incident-response-playbook">Incident Response Playbook</a></p>
<ul>
<li><p><a href="#heading-scenario-false-positive-spike">Scenario: False Positive Spike</a></p>
</li>
<li><p><a href="#heading-scenario-gradual-performance-decay">Scenario: Gradual Performance Decay</a></p>
</li>
<li><p><a href="#heading-scenario-upstream-data-schema-change">Scenario: Upstream Data Schema Change</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-9-how-to-put-it-all-together">How to Put It All Together</a></p>
</li>
<li><p><a href="#heading-10-whats-next-scale-to-production">What’s Next: Scale to Production</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a href="#heading-references">References</a></p>
</li>
</ol>
<h2 id="heading-project-overview-and-setup"><strong>Project Overview and Setup</strong></h2>
<p>Before we jump into coding, let's set the stage. Our use-case is <strong>credit card fraud detection</strong> – a binary classification problem where we predict whether a transaction is fraudulent (<code>is_fraud = 1</code>) or legitimate (<code>is_fraud = 0</code>). This is a common ML task and a good proxy for production ML challenges because fraud patterns can change over time (allowing us to discuss model drift), and bad input data (for example, malformed transaction info) can cause serious issues if not handled properly.</p>
<h3 id="heading-tech-stack"><strong>Tech Stack</strong></h3>
<p>We will use Python-based tools that are popular in MLOps but still beginner-friendly:</p>
<table>
<thead>
<tr>
<th><strong>Tool</strong></th>
<th><strong>Purpose</strong></th>
<th><strong>Why We Chose It</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>MLflow</strong></td>
<td>Experiment tracking and model registry</td>
<td>Open-source, widely adopted, great UI</td>
</tr>
<tr>
<td><strong>Feast</strong></td>
<td>Feature store for consistent feature serving</td>
<td>Production-grade, runs locally, same API for offline/online</td>
</tr>
<tr>
<td><strong>FastAPI</strong></td>
<td>High-performance web framework for serving predictions</td>
<td>Fast, automatic docs, modern Python</td>
</tr>
<tr>
<td><strong>Great Expectations</strong></td>
<td>Data validation framework</td>
<td>Declarative expectations, great reports</td>
</tr>
<tr>
<td><strong>Evidently</strong></td>
<td>Monitoring for data drift and model decay</td>
<td>Beautiful reports, easy to integrate</td>
</tr>
<tr>
<td><strong>Docker</strong></td>
<td>Containerization for environment consistency</td>
<td>Industry standard, works everywhere</td>
</tr>
<tr>
<td><strong>GitHub Actions</strong></td>
<td>CI/CD automation</td>
<td>Free for public repos, tight GitHub integration</td>
</tr>
</tbody></table>
<p>Let me explain each tool briefly:</p>
<p><strong>MLflow</strong> is an open-source platform designed to manage the ML lifecycle. It provides experiment tracking (logging parameters, metrics, and artifacts), a model registry (versioning models with aliases), and model serving capabilities. We'll use it to ensure our experiments are reproducible and our models are versioned.</p>
<p><strong>Feast</strong> (Feature Store) is an open-source feature store that helps manage and serve features consistently between training and inference. This prevents a common problem called "training-serving skew" where the features used in production differ slightly from those used in training, causing silent accuracy degradation.</p>
<p><strong>FastAPI</strong> is a modern, fast web framework for building APIs with Python. It's known for being easy to use, efficient, and producing automatic interactive documentation. We'll use it to serve our model predictions.</p>
<p><strong>Great Expectations</strong> is an open-source tool for data quality testing. It allows us to define "expectations" on data (like "amount should be positive" or "hour should be between 0 and 23") and test incoming data against them.</p>
<p><strong>Evidently</strong> is an open-source library for monitoring data and model performance over time. It can detect data drift (when input distributions change) and model decay (when accuracy drops).</p>
<p><strong>Docker</strong> ensures the same environment and dependencies in development and deployment, avoiding the classic "works on my machine" problem.</p>
<p><strong>GitHub Actions</strong> provides CI/CD automation. An efficient CI/CD pipeline helps integrate and deploy changes faster and with fewer errors.</p>
<p>💡 <strong>Mental Model</strong>: Think of this as building a "safety net" around your ML model. Each tool we add catches a different failure mode, like defensive driving for machine learning.</p>
<h3 id="heading-prerequisites"><strong>Prerequisites</strong></h3>
<p>You'll need:</p>
<ul>
<li><p><strong>Python 3.9+</strong> installed on your machine</p>
</li>
<li><p><strong>Docker Desktop</strong> installed and running</p>
</li>
<li><p><strong>GitHub account</strong> (if you want to try the CI/CD pipeline)</p>
</li>
<li><p><strong>Basic familiarity with Python</strong> and ML concepts (what training and prediction mean)</p>
</li>
</ul>
<p>You don't need MLOps or Kubernetes experience. Everything will be done locally with just Python and Docker – <strong>no cloud and no Kubernetes needed</strong>.</p>
<h3 id="heading-project-structure"><strong>Project Structure</strong></h3>
<p>Let's set up a basic project structure on your local machine. Open your terminal and run:</p>
<pre><code class="language-python"># Create project directory and subfolders
mkdir ml-platform-tutorial &amp;&amp; cd ml-platform-tutorial
mkdir -p data models src tests feature_repo

# Set up a virtual environment (recommended)
python -m venv venv
source venv/bin/activate   # On Windows: venv\Scripts\activate
</code></pre>
<p>Your project structure should look like this:</p>
<pre><code class="language-python">ml-platform-tutorial/
├── data/              # Training and test datasets
├── models/            # Saved model files
├── src/               # Source code
├── tests/             # Test files
├── feature_repo/      # Feast feature repository
├── venv/              # Virtual environment
└── requirements.txt   # Dependencies
</code></pre>
<p>Next, create a <code>requirements.txt</code> with all the necessary libraries:</p>
<pre><code class="language-python"># requirements.txt

# Core ML libraries
pandas==2.2.0
numpy==1.26.3
scikit-learn==1.4.0

# Experiment tracking and model registry
mlflow==2.10.0

# Feature store
feast==0.36.0

# API framework
fastapi==0.109.0
uvicorn==0.27.0
httpx==0.26.0

# Data validation
great-expectations==0.18.8

# Monitoring
evidently==0.7.20

# Testing
pytest==8.0.0
pytest-cov==4.1.0

# Utilities
pyarrow==15.0.0
pydantic==2.6.0
</code></pre>
<p>📌 <strong>Version Note:</strong> Exact versions are pinned to ensure reproducibility. Newer versions may work, but all examples were tested with the versions listed here.</p>
<p>Install the dependencies:</p>
<pre><code class="language-python">pip install -r requirements.txt
</code></pre>
<p>This might take a few minutes as it installs all the packages. Once complete, we're ready to start building our project step by step.</p>
<p><strong>Checkpoint:</strong> You should have a project folder with <code>data/</code>, <code>models/</code>, <code>src/</code>, <code>tests/</code>, and <code>feature_repo/</code> directories, and an activated virtual environment with all dependencies installed. Verify by running <code>python -c "import mlflow; import feast; import fastapi; print('All imports successful!')"</code>.</p>
<p><strong>Figure 1: The Complete ML Platform We'll Build</strong></p>
<p><em>Don't worry if this looks complex, we'll build each component step by step, starting with the simplest piece and connecting them together.</em></p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771392341567/4bfdd727-32fb-4f30-a63e-c94f61a9f2db.png" alt="Architecture diagram of a local end-to-end machine learning platform for fraud detection. Transaction data flows through model training, experiment tracking and model registry in MLflow, feature management in Feast, data validation with Great Expectations, prediction serving through FastAPI, monitoring with Evidently, and automated testing and deployment with Docker and GitHub Actions." style="display:block;margin:0 auto" width="2107" height="1219" loading="lazy">

<h2 id="heading-1-build-a-simple-model-and-api-the-naive-approach"><strong>1. Build a Simple Model and API (The Naive Approach)</strong></h2>
<p>To illustrate why we need all these tools, let's start by building a <strong>naive ML system without any MLOps infrastructure</strong>. We'll train a simple model and deploy it quickly, then observe what problems arise. This "naive approach" is how most ML projects start – and understanding its limitations will motivate the solutions we implement later.</p>
<h3 id="heading-11-train-a-quick-model"><strong>1.1 Train a Quick Model</strong></h3>
<p>First, we need some data. For simplicity, we'll generate a synthetic dataset for fraud detection so that we don't rely on any external data files. The dataset will have features like:</p>
<ul>
<li><p><code>amount</code>: Transaction amount in dollars</p>
</li>
<li><p><code>hour</code>: Hour of the day (0-23) when the transaction occurred</p>
</li>
<li><p><code>day_of_week</code>: Day of the week (0=Monday, 6=Sunday)</p>
</li>
<li><p><code>merchant_category</code>: Type of merchant (grocery, restaurant, retail, online, travel)</p>
</li>
<li><p><code>is_fraud</code>: Label indicating if the transaction is fraudulent (1) or legitimate (0)</p>
</li>
</ul>
<p>We will simulate that only ~2% of transactions are fraud, which is an imbalance typical in real fraud data. This imbalance is important because it affects how we evaluate our model.</p>
<p>Create <code>src/generate_data.py</code>:</p>
<pre><code class="language-python"># src/generate_data.py
"""
Generate synthetic fraud detection dataset.

This script creates realistic-looking transaction data where fraudulent
transactions have different patterns than legitimate ones:
- Fraud tends to have higher amounts
- Fraud tends to occur late at night
- Fraud is more common for online and travel merchants
"""
import pandas as pd
import numpy as np

def generate_transactions(n_samples=10000, fraud_ratio=0.02, seed=42):
    """
    Generate synthetic fraud detection dataset.
    
    Args:
        n_samples: Total number of transactions to generate
        fraud_ratio: Proportion of fraudulent transactions (default 2%)
        seed: Random seed for reproducibility
    
    Returns:
        DataFrame with transaction features and fraud labels
    
    Fraud transactions have different patterns:
    - Higher amounts (mean \(245 vs \)33 for legit)
    - Late night hours (0-5, 23)
    - More likely to be online or travel merchants
    """
    np.random.seed(seed)
    n_fraud = int(n_samples * fraud_ratio)
    n_legit = n_samples - n_fraud

    # Legitimate transactions: normal shopping patterns
    # - Amounts follow a log-normal distribution (most small, some large)
    # - Hours are uniformly distributed throughout the day
    # - Merchant categories weighted toward everyday shopping
    legit = pd.DataFrame({
        "amount": np.random.lognormal(mean=3.5, sigma=1.2, size=n_legit),  # ~$33 average
        "hour": np.random.randint(0, 24, size=n_legit),
        "day_of_week": np.random.randint(0, 7, size=n_legit),
        "merchant_category": np.random.choice(
            ["grocery", "restaurant", "retail", "online", "travel"],
            size=n_legit,
            p=[0.30, 0.25, 0.25, 0.15, 0.05]  # Weighted toward everyday shopping
        ),
        "is_fraud": 0
    })
    
    # Fraudulent transactions: suspicious patterns
    # - Higher amounts (fraudsters go big)
    # - Late night hours (less scrutiny)
    # - More online and travel (easier to exploit)
    fraud = pd.DataFrame({
        "amount": np.random.lognormal(mean=5.5, sigma=1.5, size=n_fraud),  # ~$245 average
        "hour": np.random.choice([0, 1, 2, 3, 4, 5, 23], size=n_fraud),  # Late night
        "day_of_week": np.random.randint(0, 7, size=n_fraud),
        "merchant_category": np.random.choice(
            ["grocery", "restaurant", "retail", "online", "travel"],
            size=n_fraud,
            p=[0.05, 0.05, 0.10, 0.60, 0.20]  # Weighted toward online/travel
        ),
        "is_fraud": 1
    })
    
    # Combine and shuffle
    df = pd.concat([legit, fraud], ignore_index=True)
    df = df.sample(frac=1, random_state=seed).reset_index(drop=True)
    
    return df

if __name__ == "__main__":
    # Generate dataset
    print("Generating synthetic fraud detection dataset...")
    df = generate_transactions(n_samples=10000, fraud_ratio=0.02)
    
    # Split into train (80%) and test (20%)
    train_df = df.sample(frac=0.8, random_state=42)
    test_df = df.drop(train_df.index)
    
    # Save to CSV files
    train_df.to_csv("data/train.csv", index=False)
    test_df.to_csv("data/test.csv", index=False)
    
    # Print summary statistics
    print(f"\nDataset generated successfully!")
    print(f"Training set: {len(train_df):,} transactions")
    print(f"Test set: {len(test_df):,} transactions")
    print(f"Overall fraud ratio: {df['is_fraud'].mean():.2%}")
    print(f"\nLegitimate transactions - Average amount: ${df[df['is_fraud']==0]['amount'].mean():.2f}")
    print(f"Fraudulent transactions - Average amount: ${df[df['is_fraud']==1]['amount'].mean():.2f}")
    print(f"\nMerchant category distribution (fraud):")
    print(df[df['is_fraud']==1]['merchant_category'].value_counts(normalize=True))
</code></pre>
<p>Run the data generation script:</p>
<pre><code class="language-python">python src/generate_data.py
</code></pre>
<p>You should see output like:</p>
<pre><code class="language-python">Generating synthetic fraud detection dataset...

Dataset generated successfully!
Training set: 8,000 transactions
Test set: 2,000 transactions
Overall fraud ratio: 2.00%

Legitimate transactions - Average amount: $33.45
Fraudulent transactions - Average amount: $245.67

Merchant category distribution (fraud):
online        0.60
travel        0.20
retail        0.10
restaurant    0.05
grocery       0.05
</code></pre>
<p>Now you have <code>data/train.csv</code> and <code>data/test.csv</code> with ~8000 training and ~2000 testing transactions.</p>
<p><strong>Why This Matters:</strong> The synthetic data has realistic patterns — fraud is rare (2%), high-value, late-night, and concentrated in certain merchant categories. These patterns give our model something to learn.</p>
<p>Now, let's train a quick model. We'll use a simple <strong>Random Forest classifier</strong> from scikit-learn to predict <code>is_fraud</code>. In this naive version, we won't do much feature engineering – just label encode the categorical <code>merchant_category</code> and feed everything to the model.</p>
<p>Create <code>src/train_naive.py</code>:</p>
<pre><code class="language-python"># src/train_naive.py
"""
Train a fraud detection model - NAIVE VERSION.

This script demonstrates the "quick and dirty" approach to ML:
- No experiment tracking
- No model versioning
- Just train and save to a pickle file

We'll improve on this in later sections.
"""
import pandas as pd
import pickle
from sklearn.ensemble import RandomForestClassifier
from sklearn.preprocessing import LabelEncoder
from sklearn.metrics import (
    accuracy_score, 
    f1_score, 
    precision_score, 
    recall_score,
    confusion_matrix,
    classification_report
)

def main():
    print("Loading data...")
    train_df = pd.read_csv("data/train.csv")
    test_df = pd.read_csv("data/test.csv")
    
    print(f"Training samples: {len(train_df):,}")
    print(f"Test samples: {len(test_df):,}")
    print(f"Training fraud ratio: {train_df['is_fraud'].mean():.2%}")
    
    # Encode the categorical feature
    # We need to save the encoder to use the same mapping at inference time
    print("\nEncoding categorical features...")
    encoder = LabelEncoder()
    train_df["merchant_encoded"] = encoder.fit_transform(train_df["merchant_category"])
    test_df["merchant_encoded"] = encoder.transform(test_df["merchant_category"])
    
    print(f"Merchant category mapping: {dict(zip(encoder.classes_, encoder.transform(encoder.classes_)))}")
    
    # Prepare features and labels
    feature_cols = ["amount", "hour", "day_of_week", "merchant_encoded"]
    X_train = train_df[feature_cols]
    y_train = train_df["is_fraud"]
    X_test = test_df[feature_cols]
    y_test = test_df["is_fraud"]
    
    # Train a Random Forest classifier
    print("\nTraining Random Forest model...")
    model = RandomForestClassifier(
        n_estimators=100,      # Number of trees
        max_depth=10,          # Maximum depth of each tree
        random_state=42,       # For reproducibility
        n_jobs=-1              # Use all CPU cores
    )
    model.fit(X_train, y_train)
    print("Training complete!")
    
    # Evaluate on test data
    print("\n" + "="*50)
    print("MODEL EVALUATION")
    print("="*50)
    
    y_pred = model.predict(X_test)
    y_prob = model.predict_proba(X_test)[:, 1]
    
    print(f"\nAccuracy:  {accuracy_score(y_test, y_pred):.4f}")
    print(f"Precision: {precision_score(y_test, y_pred):.4f}")
    print(f"Recall:    {recall_score(y_test, y_pred):.4f}")
    print(f"F1-score:  {f1_score(y_test, y_pred):.4f}")
    
    print("\nConfusion Matrix:")
    cm = confusion_matrix(y_test, y_pred)
    print(f"  True Negatives:  {cm[0][0]:,} (correctly identified legitimate)")
    print(f"  False Positives: {cm[0][1]:,} (legitimate flagged as fraud)")
    print(f"  False Negatives: {cm[1][0]:,} (fraud missed - DANGEROUS!)")
    print(f"  True Positives:  {cm[1][1]:,} (correctly caught fraud)")
    
    print("\nClassification Report:")
    print(classification_report(y_test, y_pred, target_names=['Legitimate', 'Fraud']))
    
    # Feature importance
    print("\nFeature Importance:")
    for name, importance in sorted(
        zip(feature_cols, model.feature_importances_),
        key=lambda x: x[1],
        reverse=True
    ):
        print(f"  {name}: {importance:.4f}")
    
    # Save the model and encoder together
    print("\nSaving model to models/model.pkl...")
    with open("models/model.pkl", "wb") as f:
        pickle.dump((model, encoder), f)
    
    print("\nModel trained and saved successfully!")
    print("\nWARNING: This naive approach has several problems:")
    print("  - No record of hyperparameters or metrics")
    print("  - No model versioning")
    print("  - No way to reproduce this exact model")
    print("  - We'll fix these issues in the following sections!")

if __name__ == "__main__":
    main()
</code></pre>
<p>Run the training script:</p>
<pre><code class="language-python">python src/train_naive.py
</code></pre>
<p>You should see output similar to:</p>
<pre><code class="language-python">Loading data...
Training samples: 8,000
Test samples: 2,000
Training fraud ratio: 2.00%

Encoding categorical features...
Merchant category mapping: {'grocery': 0, 'online': 1, 'restaurant': 2, 'retail': 3, 'travel': 4}

Training Random Forest model...
Training complete!

==================================================
MODEL EVALUATION
==================================================

Accuracy:  0.9820
Precision: 0.7273
Recall:    0.6154
F1-score:  0.6667

Confusion Matrix:
  True Negatives:  1,956 (correctly identified legitimate)
  False Positives: 4 (legitimate flagged as fraud)
  False Negatives: 32 (fraud missed - DANGEROUS!)
  True Positives:  8 (correctly caught fraud)

Feature Importance:
  amount: 0.5423
  hour: 0.2156
  merchant_encoded: 0.1345
  day_of_week: 0.1076
</code></pre>
<p><strong>Important observation:</strong> You'll see ~98% accuracy but a lower F1-score (around 0.5-0.7). <strong>With only 2% fraud, accuracy is extremely misleading!</strong> A model that always predicts "not fraud" would achieve 98% accuracy while catching zero fraud. This is why we focus on F1-score, precision, and recall for imbalanced classification problems.</p>
<p>💡 If you're new to imbalanced classification, remember: high accuracy can be meaningless when the positive class is rare.</p>
<p>The script outputs a file <code>models/model.pkl</code> containing both the trained model and the label encoder (we need both for inference).</p>
<p><strong>Checkpoint:</strong> You should now have:</p>
<ul>
<li><p><code>data/train.csv</code> (~8,000 rows)</p>
</li>
<li><p><code>data/test.csv</code> (~2,000 rows)</p>
</li>
<li><p><code>models/model.pkl</code> (trained model + encoder)</p>
</li>
</ul>
<p>The model should show ~98% accuracy but F1 around 0.5-0.7. Verify the files exist: <code>ls -la data/ models/</code></p>
<h3 id="heading-12-serve-predictions-with-fastapi"><strong>1.2 Serve Predictions with FastAPI</strong></h3>
<p>Now that we have a model, let's deploy it as an API so that clients can get predictions. We'll use <strong>FastAPI</strong> because it's straightforward, very fast, and produces automatic interactive documentation.</p>
<p>FastAPI is known for:</p>
<ul>
<li><p><strong>Easy to use</strong>: Pythonic syntax with type hints</p>
</li>
<li><p><strong>High performance</strong>: One of the fastest Python frameworks</p>
</li>
<li><p><strong>Automatic documentation</strong>: Swagger UI out of the box</p>
</li>
<li><p><strong>Data validation</strong>: Using Pydantic models</p>
</li>
</ul>
<p>Create <code>src/serve_naive.py</code>:</p>
<pre><code class="language-python"># src/serve_naive.py
"""
Serve fraud detection model as a REST API - NAIVE VERSION.

This is a simple API that:
1. Loads the trained model at startup
2. Accepts transaction data via POST request
3. Returns fraud prediction

We'll improve this with validation, monitoring, and better
model loading in later sections.
"""
import pickle
from fastapi import FastAPI
from pydantic import BaseModel, Field
from typing import Optional

# Load the trained model and encoder at startup
# This is loaded once when the server starts, not on every request
print("Loading model...")
with open("models/model.pkl", "rb") as f:
    model, encoder = pickle.load(f)
print("Model loaded successfully!")

# Create the FastAPI application
app = FastAPI(
    title="Fraud Detection API",
    description="""
    Predict whether a credit card transaction is fraudulent.
    
    This API accepts transaction details and returns:
    - Whether the transaction is predicted to be fraud
    - The probability of fraud (0.0 to 1.0)
    
    **Note:** This is the naive version without validation or monitoring.
    """,
    version="1.0.0"
)

# Define the input schema using Pydantic
# This provides automatic validation and documentation
class Transaction(BaseModel):
    """Schema for a transaction to be evaluated for fraud."""
    amount: float = Field(
        ..., 
        description="Transaction amount in dollars",
        example=150.00
    )
    hour: int = Field(
        ..., 
        description="Hour of the day (0-23)",
        example=14
    )
    day_of_week: int = Field(
        ..., 
        description="Day of week (0=Monday, 6=Sunday)",
        example=3
    )
    merchant_category: str = Field(
        ..., 
        description="Type of merchant",
        example="online"
    )

class PredictionResponse(BaseModel):
    """Schema for the prediction response."""
    is_fraud: bool = Field(description="Whether the transaction is predicted as fraud")
    fraud_probability: float = Field(description="Probability of fraud (0.0 to 1.0)")
    
@app.post("/predict", response_model=PredictionResponse)
def predict(transaction: Transaction):
    """
    Predict whether a transaction is fraudulent.
    
    Takes transaction details and returns a fraud prediction
    along with the probability score.
    """
    # Convert the request to a dictionary
    data = transaction.dict()
    
    # Encode the merchant category using the same encoder from training
    # This ensures consistency between training and serving
    try:
        data["merchant_encoded"] = encoder.transform([data["merchant_category"]])[0]
    except ValueError:
        # Handle unknown merchant categories
        # In production, we'd want better handling here
        data["merchant_encoded"] = 0
    
    # Prepare features in the same order as training
    X = [[
        data["amount"],
        data["hour"],
        data["day_of_week"],
        data["merchant_encoded"]
    ]]
    
    # Get prediction and probability
    prediction = model.predict(X)[0]
    probability = model.predict_proba(X)[0][1]  # Probability of class 1 (fraud)
    
    return PredictionResponse(
        is_fraud=bool(prediction),
        fraud_probability=round(float(probability), 4)
    )

@app.get("/health")
def health_check():
    """
    Health check endpoint.
    
    Returns the status of the API. Useful for:
    - Load balancer health checks
    - Kubernetes liveness probes
    - Monitoring systems
    """
    return {
        "status": "healthy",
        "model_loaded": model is not None
    }

@app.get("/")
def root():
    """Root endpoint with API information."""
    return {
        "message": "Fraud Detection API",
        "version": "1.0.0",
        "docs": "/docs",
        "health": "/health"
    }
</code></pre>
<p>A few important things to note about this code:</p>
<ol>
<li><p><strong>Pydantic Models</strong>: We use <code>BaseModel</code> to define the expected input JSON schema. FastAPI automatically validates incoming requests against this schema.</p>
</li>
<li><p><strong>Type Hints</strong>: The type hints (<code>float</code>, <code>int</code>, <code>str</code>) provide both documentation and runtime validation.</p>
</li>
<li><p><strong>Feature Encoding</strong>: On each request, we encode the merchant category using the same <code>LabelEncoder</code> we saved from training. This ensures consistency between training and serving.</p>
</li>
<li><p><strong>Health Endpoint</strong>: The <code>/health</code> endpoint is standard practice for production APIs - it allows load balancers and monitoring systems to check if the service is running.</p>
</li>
</ol>
<p>To run this API, use Uvicorn (an ASGI server):</p>
<pre><code class="language-python">uvicorn src.serve_naive:app --reload --host 0.0.0.0 --port 8000
</code></pre>
<p>The <code>--reload</code> flag enables auto-reload during development (the server restarts when you change code).</p>
<p>You should see:</p>
<pre><code class="language-python">Loading model...
Model loaded successfully!
INFO:     Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
INFO:     Started reloader process
</code></pre>
<p>Now open your browser and go to <code>http://localhost:8000/docs</code>. You'll see the <strong>Swagger UI</strong> – an auto-generated interactive documentation where you can test the API directly from your browser!</p>
<p>Test the API using curl in another terminal:</p>
<pre><code class="language-python"># Test with a legitimate-looking transaction
curl -X POST "http://localhost:8000/predict" \
  -H "Content-Type: application/json" \
  -d '{"amount": 50.0, "hour": 14, "day_of_week": 3, "merchant_category": "grocery"}'
</code></pre>
<p>Expected response:</p>
<pre><code class="language-python">{"is_fraud": false, "fraud_probability": 0.02}
</code></pre>
<pre><code class="language-python"># Test with a suspicious transaction (high amount, late night, online)
curl -X POST "http://localhost:8000/predict" \
  -H "Content-Type: application/json" \
  -d '{"amount": 500.0, "hour": 3, "day_of_week": 1, "merchant_category": "online"}'
</code></pre>
<p>Expected response:</p>
<pre><code class="language-python">{"is_fraud": true, "fraud_probability": 0.78}
</code></pre>
<p><strong>We have a working model served as an API!</strong> In a real scenario, we could now integrate this API with a payment processing frontend, mobile app, or any system that needs fraud predictions.</p>
<p>But before we celebrate, let's examine this naive approach for potential pitfalls...</p>
<p><strong>Checkpoint:</strong> Your API should be running at <code>http://localhost:8000</code>. The Swagger UI at <code>/docs</code> should show both endpoints (<code>/predict</code> and <code>/health</code>). Test with curl or the Swagger UI to verify predictions are returned.</p>
<h2 id="heading-2-where-the-naive-approach-breaks"><strong>2. Where the Naive Approach Breaks</strong></h2>
<p>Our quick-and-dirty ML pipeline works on the surface: it can train a model and serve predictions. However, <strong>hidden problems will emerge</strong> if we try to maintain or scale this system in production.</p>
<p>This section is critical: understanding these issues will motivate the solutions we implement in the following sections. Let's go through the problems one by one.</p>
<h3 id="heading-problem-1-no-experiment-tracking-reproducibility"><strong>Problem 1: No Experiment Tracking (Reproducibility)</strong></h3>
<p>Try this thought experiment: Run <code>train_naive.py</code> again with different hyperparameters (change <code>n_estimators</code> to 200, or <code>max_depth</code> to 15). Would you be able to <strong>exactly reproduce the previous model's results</strong> if someone asked?</p>
<p>Probably not. Currently, we have <strong>no record</strong> of:</p>
<ul>
<li><p>Which hyperparameters we used</p>
</li>
<li><p>What metrics we achieved</p>
</li>
<li><p>What version of the data we trained on</p>
</li>
<li><p>What library versions were installed</p>
</li>
<li><p>When the training happened</p>
</li>
<li><p>Who ran the training</p>
</li>
</ul>
<p>Three months from now, if your manager asks "How was this model trained? Can you reproduce the results?" – you'd be in trouble. You might have the code, but you don't know which version of the code, which parameters, or which data produced the model that's currently in production.</p>
<p><strong>Experiment tracking</strong> is the practice of logging all these details (code versions, parameters, metrics, data versions, artifacts) so experiments can be compared and replicated. Our naive approach lacks this entirely, making our results hard to trust or build upon.</p>
<h3 id="heading-problem-2-model-versioning-and-deployment-chaos"><strong>Problem 2: Model Versioning and Deployment Chaos</strong></h3>
<p>We trained one model and saved it as <code>model.pkl</code>. Now consider this scenario:</p>
<ol>
<li><p>You train a new model with different hyperparameters</p>
</li>
<li><p>You overwrite <code>model.pkl</code> with the new model</p>
</li>
<li><p>You deploy it to production</p>
</li>
<li><p>Users start complaining about more false positives</p>
</li>
<li><p>You want to roll back to the previous model</p>
</li>
<li><p><strong>Problem:</strong> The previous model was overwritten and is gone forever</p>
</li>
</ol>
<p>There's no systematic versioning. Questions you cannot answer:</p>
<ul>
<li><p>Which model version is currently in production?</p>
</li>
<li><p>What were the metrics for model v1 vs v2?</p>
</li>
<li><p>When was each model trained and by whom?</p>
</li>
<li><p>Can we instantly roll back if the new model performs worse?</p>
</li>
<li><p>What changed between versions?</p>
</li>
</ul>
<p>Without version control for models, you're flying blind. Imagine deploying code without Git – that's what we're doing with our model.</p>
<h3 id="heading-problem-3-no-data-validation-garbage-in-garbage-out"><strong>Problem 3: No Data Validation – Garbage In, Garbage Out</strong></h3>
<p>Right now, our API will accept <strong>any input</strong> and try to make a prediction. Let's see what happens with bad data.</p>
<p>Create a test script <code>src/test_bad_data.py</code>:</p>
<pre><code class="language-python"># src/test_bad_data.py
"""Test what happens when we send garbage data to the API."""
import requests

BASE_URL = "http://localhost:8000"

print("Testing API with various bad inputs...\n")

# Test 1: Negative amount
print("Test 1: Negative amount")
response = requests.post(f"{BASE_URL}/predict", json={
    "amount": -500.0,        # Negative amount - impossible!
    "hour": 14,
    "day_of_week": 3,
    "merchant_category": "online"
})
print(f"  Status: {response.status_code}")
print(f"  Response: {response.json()}\n")

# Test 2: Invalid hour
print("Test 2: Hour = 25 (should be 0-23)")
response = requests.post(f"{BASE_URL}/predict", json={
    "amount": 100.0,
    "hour": 25,              # Invalid hour!
    "day_of_week": 3,
    "merchant_category": "online"
})
print(f"  Status: {response.status_code}")
print(f"  Response: {response.json()}\n")

# Test 3: Invalid day of week
print("Test 3: day_of_week = 10 (should be 0-6)")
response = requests.post(f"{BASE_URL}/predict", json={
    "amount": 100.0,
    "hour": 14,
    "day_of_week": 10,       # Invalid day!
    "merchant_category": "online"
})
print(f"  Status: {response.status_code}")
print(f"  Response: {response.json()}\n")

# Test 4: Unknown merchant category
print("Test 4: Unknown merchant category")
response = requests.post(f"{BASE_URL}/predict", json={
    "amount": 100.0,
    "hour": 14,
    "day_of_week": 3,
    "merchant_category": "unknown_category"  # Not in training data!
})
print(f"  Status: {response.status_code}")
print(f"  Response: {response.json()}\n")

# Test 5: All bad at once
print("Test 5: Everything wrong")
response = requests.post(f"{BASE_URL}/predict", json={
    "amount": -1000.0,
    "hour": 99,
    "day_of_week": 15,
    "merchant_category": "totally_fake"
})
print(f"  Status: {response.status_code}")
print(f"  Response: {response.json()}\n")

print("Observation: The API happily accepts ALL garbage and returns predictions!")
print("This is dangerous - bad data leads to bad predictions with no warning.")
</code></pre>
<p>Run it (make sure your API is still running):</p>
<pre><code class="language-python">python src/test_bad_data.py
</code></pre>
<p>You'll see something like:</p>
<pre><code class="language-python">Testing API with various bad inputs...

Test 1: Negative amount
  Status: 200
  Response: {'is_fraud': False, 'fraud_probability': 0.15}

Test 2: Hour = 25 (should be 0-23)
  Status: 200
  Response: {'is_fraud': False, 'fraud_probability': 0.08}

...

Observation: The API happily accepts ALL garbage and returns predictions!
</code></pre>
<p><strong>The API accepts garbage and returns predictions with no warning!</strong> In production, this could mean:</p>
<ul>
<li><p>Incorrect predictions based on impossible data</p>
</li>
<li><p>Fraud going undetected because of malformed input</p>
</li>
<li><p>Legitimate transactions blocked based on corrupted data</p>
</li>
<li><p>No way to debug why predictions are wrong</p>
</li>
</ul>
<p>As the saying goes: <strong>"Garbage in, garbage out."</strong> But even worse – we don't even know garbage went in!</p>
<h3 id="heading-problem-4-model-drift-performance-decay-over-time"><strong>Problem 4: Model Drift – Performance Decay Over Time</strong></h3>
<p>Here's a scenario that happens in every production ML system:</p>
<ol>
<li><p><strong>January</strong>: You train your model on historical fraud data. It achieves 98% accuracy and 0.67 F1-score. Everyone's happy.</p>
</li>
<li><p><strong>February</strong>: The model is deployed and working well. Fraud is being caught.</p>
</li>
<li><p><strong>March</strong>: Fraudsters adapt. They start using different patterns – smaller amounts, different merchant categories, different times of day.</p>
</li>
<li><p><strong>April</strong>: Your model's accuracy has dropped from 98% to 85%. F1-score dropped from 0.67 to 0.35. Fraud is slipping through.</p>
</li>
<li><p><strong>May</strong>: A major fraud incident occurs. Investigation reveals the model has been underperforming for 2 months.</p>
</li>
</ol>
<p><strong>The problem:</strong> Nobody noticed for 2 months because there was no monitoring.</p>
<p>This phenomenon is called <strong>data drift</strong> (when input data distributions change) or <strong>concept drift</strong> (when the relationship between inputs and outputs changes). Both are inevitable in real-world systems.</p>
<p>Without monitoring:</p>
<ul>
<li><p>You don't know when performance degrades</p>
</li>
<li><p>You don't know why performance degrades</p>
</li>
<li><p>You can't take corrective action until users complain</p>
</li>
<li><p>By then, significant damage may have occurred</p>
</li>
</ul>
<h3 id="heading-problem-5-no-cicd-or-deployment-safety"><strong>Problem 5: No CI/CD or Deployment Safety</strong></h3>
<p>Our "deployment process" was literally:</p>
<ol>
<li><p>SSH into the server (or run locally)</p>
</li>
<li><p>Run <code>python src/train_naive.py</code></p>
</li>
<li><p>Copy model.pkl to the right place</p>
</li>
<li><p>Restart the API</p>
</li>
<li><p>Hope for the best</p>
</li>
</ol>
<p>There's:</p>
<ul>
<li><p><strong>No automated testing</strong>: A typo could break everything</p>
</li>
<li><p><strong>No staging environment</strong>: We test directly in production</p>
</li>
<li><p><strong>No gradual rollout</strong>: 100% of traffic hits the new model immediately</p>
</li>
<li><p><strong>No rollback capability</strong>: If something breaks, we have to manually fix it</p>
</li>
<li><p><strong>No audit trail</strong>: Who deployed what and when?</p>
</li>
</ul>
<p>This is how production incidents happen. A rushed deployment at 5 PM on Friday breaks the fraud detection system, and nobody notices until Monday when fraud losses have spiked.</p>
<p><strong>Figure 2:</strong> Problems with the Naive Approach</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771392425864/75c51059-5ab3-4e08-b3ad-7f5e9c3e7445.png" alt="Diagram showing the weaknesses of a naive machine learning setup: manual training and deployment, no experiment tracking, no model versioning, inconsistent features between training and serving, no data validation, no drift or performance monitoring, and no CI/CD safeguards such as automated tests, rollback, or audit trail." style="display:block;margin:0 auto" width="2107" height="1056" loading="lazy">

<h3 id="heading-summary-what-we-need-to-fix"><strong>Summary: What We Need to Fix</strong></h3>
<p>Our simple ML service is missing critical infrastructure. Here's the mapping of problems to solutions:</p>
<table>
<thead>
<tr>
<th><strong>Problem</strong></th>
<th><strong>Impact</strong></th>
<th><strong>Solution</strong></th>
<th><strong>Section</strong></th>
</tr>
</thead>
<tbody><tr>
<td>No experiment tracking</td>
<td>Can't reproduce or compare models</td>
<td>MLflow Tracking</td>
<td>3</td>
</tr>
<tr>
<td>No model versioning</td>
<td>Can't roll back or audit</td>
<td>MLflow Registry</td>
<td>3</td>
</tr>
<tr>
<td>No feature consistency</td>
<td>Training-serving skew</td>
<td>Feast Feature Store</td>
<td>4</td>
</tr>
<tr>
<td>No data validation</td>
<td>Garbage predictions</td>
<td>Great Expectations</td>
<td>5</td>
</tr>
<tr>
<td>No monitoring</td>
<td>Drift goes unnoticed</td>
<td>Evidently</td>
<td>6</td>
</tr>
<tr>
<td>No CI/CD</td>
<td>Risky deployments</td>
<td>GitHub Actions + Docker</td>
<td>7</td>
</tr>
</tbody></table>
<p><strong>The good news:</strong> We can fix each of these by incrementally adding components to our pipeline. Each tool addresses a specific problem, and together they form a robust ML platform.</p>
<p>Let's start fixing these issues, one by one.</p>
<h2 id="heading-3-add-experiment-tracking-and-model-registry-with-mlflow"><strong>3. Add Experiment Tracking and Model Registry with MLflow</strong></h2>
<p><strong>What breaks without this:</strong> You can't reproduce yesterday's results, can't compare experiments, and can't roll back when a new model fails in production.</p>
<p>Our first fix addresses <strong>Problems 1 and 2</strong>: experiment reproducibility and model versioning.</p>
<p><strong>MLflow</strong> is an open-source platform designed to manage the ML lifecycle. We'll use two of its key components:</p>
<ol>
<li><p><strong>MLflow Tracking</strong>: Log experiments (parameters, metrics, artifacts) so you can compare runs and reproduce results</p>
</li>
<li><p><strong>MLflow Model Registry</strong>: Version your models with aliases (champion, challenger) and manage the deployment lifecycle</p>
</li>
</ol>
<p><strong>Why This Matters:</strong> Without tracking, ML is guesswork. With MLflow, every run is logged with parameters, metrics, and artifacts. You can compare runs side-by-side, understand what actually improved your model, and reproduce any past experiment. The Model Registry adds governance – you know exactly which model is in production and can roll back in seconds.</p>
<h3 id="heading-31-how-to-set-up-the-mlflow-tracking-server"><strong>3.1</strong> How to Set Up the MLflow Tracking Server</h3>
<p>MLflow can log experiments to a local directory by default, but to use the full UI and model registry, it's best to run the MLflow tracking server.</p>
<p>Open a <strong>new terminal</strong> (keep it separate from your API terminal) and run:</p>
<pre><code class="language-python"># Create a directory for MLflow data
mkdir -p mlruns

# Start the MLflow server
mlflow server \
    --host 0.0.0.0 \
    --port 5000 \
    --backend-store-uri sqlite:///mlflow.db \
    --default-artifact-root ./mlruns
</code></pre>
<p>Let's break down these parameters:</p>
<ul>
<li><p><code>--host 0.0.0.0</code>: Listen on all network interfaces</p>
</li>
<li><p><code>--port 5000</code>: Run on port 5000</p>
</li>
<li><p><code>--backend-store-uri sqlite:///mlflow.db</code>: Store experiment metadata in a SQLite database (for production, you'd use PostgreSQL or MySQL)</p>
</li>
<li><p><code>--default-artifact-root ./mlruns</code>: Store model artifacts (files) in the <code>mlruns</code> directory</p>
</li>
</ul>
<p>You should see:</p>
<pre><code class="language-python">[INFO] Starting gunicorn 21.2.0
[INFO] Listening at: http://0.0.0.0:5000
</code></pre>
<p>Now open your browser and navigate to <code>http://localhost:5000</code>. You'll see the <strong>MLflow UI</strong> – it should be empty initially since we haven't logged any experiments yet.</p>
<h3 id="heading-32-how-to-log-experiments-in-code"><strong>3.2</strong> How to Log Experiments in Code</h3>
<p>Now let's modify our training script to log everything to MLflow. Create <code>src/train_mlflow.py</code>:</p>
<pre><code class="language-python"># src/train_mlflow.py
"""
Train fraud detection model with MLflow experiment tracking.

This script demonstrates proper ML experiment tracking:
- Log all hyperparameters
- Log all metrics (train and test)
- Log the trained model as an artifact
- Register the model in the Model Registry

Compare this to train_naive.py to see the difference!
"""
import pandas as pd
import mlflow
import mlflow.sklearn
from sklearn.ensemble import RandomForestClassifier
from sklearn.preprocessing import LabelEncoder
from sklearn.metrics import (
    accuracy_score, 
    precision_score, 
    recall_score, 
    f1_score,
    roc_auc_score
)
import pickle
from datetime import datetime

# Configure MLflow to use our tracking server
mlflow.set_tracking_uri("http://localhost:5000")

# Create or get the experiment
# All runs will be grouped under this experiment name
mlflow.set_experiment("fraud-detection")

def load_and_preprocess_data():
    """Load and preprocess the training and test data."""
    print("Loading data...")
    train_df = pd.read_csv("data/train.csv")
    test_df = pd.read_csv("data/test.csv")
    
    # Encode categorical feature
    encoder = LabelEncoder()
    train_df["merchant_encoded"] = encoder.fit_transform(train_df["merchant_category"])
    test_df["merchant_encoded"] = encoder.transform(test_df["merchant_category"])
    
    # Prepare features
    feature_cols = ["amount", "hour", "day_of_week", "merchant_encoded"]
    X_train = train_df[feature_cols]
    y_train = train_df["is_fraud"]
    X_test = test_df[feature_cols]
    y_test = test_df["is_fraud"]
    
    return X_train, y_train, X_test, y_test, encoder

def train_and_log_model(
    n_estimators: int = 100,
    max_depth: int = 10,
    min_samples_split: int = 2,
    min_samples_leaf: int = 1
):
    """
    Train a model and log everything to MLflow.
    
    Args:
        n_estimators: Number of trees in the forest
        max_depth: Maximum depth of each tree
        min_samples_split: Minimum samples required to split a node
        min_samples_leaf: Minimum samples required at a leaf node
    """
    X_train, y_train, X_test, y_test, encoder = load_and_preprocess_data()
    
    # Start an MLflow run - everything logged will be associated with this run
    with mlflow.start_run():
        # Add a descriptive run name
        run_name = f"rf_est{n_estimators}_depth{max_depth}_{datetime.now().strftime('%H%M%S')}"
        mlflow.set_tag("mlflow.runName", run_name)
        
        # Log all hyperparameters
        # These are the "knobs" we can tune
        mlflow.log_param("n_estimators", n_estimators)
        mlflow.log_param("max_depth", max_depth)
        mlflow.log_param("min_samples_split", min_samples_split)
        mlflow.log_param("min_samples_leaf", min_samples_leaf)
        mlflow.log_param("model_type", "RandomForestClassifier")
        
        # Log data information
        mlflow.log_param("train_samples", len(X_train))
        mlflow.log_param("test_samples", len(X_test))
        mlflow.log_param("fraud_ratio", float(y_train.mean()))
        mlflow.log_param("n_features", X_train.shape[1])
        
        # Train the model
        print(f"\nTraining model: n_estimators={n_estimators}, max_depth={max_depth}")
        model = RandomForestClassifier(
            n_estimators=n_estimators,
            max_depth=max_depth,
            min_samples_split=min_samples_split,
            min_samples_leaf=min_samples_leaf,
            random_state=42,
            n_jobs=-1
        )
        model.fit(X_train, y_train)
        
        # Evaluate and log metrics for BOTH train and test sets
        # This helps detect overfitting
        for dataset_name, X, y in [("train", X_train, y_train), ("test", X_test, y_test)]:
            y_pred = model.predict(X)
            y_prob = model.predict_proba(X)[:, 1]
            
            # Calculate all metrics
            accuracy = accuracy_score(y, y_pred)
            precision = precision_score(y, y_pred, zero_division=0)
            recall = recall_score(y, y_pred, zero_division=0)
            f1 = f1_score(y, y_pred, zero_division=0)
            roc_auc = roc_auc_score(y, y_prob)
            
            # Log metrics with dataset prefix
            mlflow.log_metric(f"{dataset_name}_accuracy", accuracy)
            mlflow.log_metric(f"{dataset_name}_precision", precision)
            mlflow.log_metric(f"{dataset_name}_recall", recall)
            mlflow.log_metric(f"{dataset_name}_f1", f1)
            mlflow.log_metric(f"{dataset_name}_roc_auc", roc_auc)
            
            print(f"  {dataset_name.upper()} - Accuracy: {accuracy:.4f}, F1: {f1:.4f}, ROC-AUC: {roc_auc:.4f}")
        
        # Log feature importance
        for feature, importance in zip(
            ["amount", "hour", "day_of_week", "merchant_encoded"],
            model.feature_importances_
        ):
            mlflow.log_metric(f"importance_{feature}", importance)
        
        # Log the model to MLflow AND register it in the Model Registry
        # This creates a new version of the model automatically
        print("\nRegistering model in MLflow Model Registry...")
        mlflow.sklearn.log_model(
            sk_model=model,
            artifact_path="model",
            registered_model_name="fraud-detection-model",
            input_example=X_train.iloc[:5]  # Example input for documentation
        )
        
        # Save and log the encoder as a separate artifact
        # We need this for inference
        with open("encoder.pkl", "wb") as f:
            pickle.dump(encoder, f)
        mlflow.log_artifact("encoder.pkl")
        
        # Get the run ID for reference
        run_id = mlflow.active_run().info.run_id
        print(f"\nMLflow Run ID: {run_id}")
        print(f"View this run: http://localhost:5000/#/experiments/1/runs/{run_id}")
        
        return model, encoder

def run_experiment_sweep():
    """
    Run multiple experiments with different hyperparameters.
    
    This demonstrates how MLflow helps compare different configurations.
    """
    print("="*60)
    print("RUNNING HYPERPARAMETER EXPERIMENT SWEEP")
    print("="*60)
    
    # Define different configurations to try
    experiments = [
        {"n_estimators": 50, "max_depth": 5},
        {"n_estimators": 100, "max_depth": 10},
        {"n_estimators": 100, "max_depth": 15},
        {"n_estimators": 200, "max_depth": 10},
        {"n_estimators": 200, "max_depth": 20},
    ]
    
    for i, params in enumerate(experiments, 1):
        print(f"\n--- Experiment {i}/{len(experiments)} ---")
        train_and_log_model(**params)
    
    print("\n" + "="*60)
    print("EXPERIMENT SWEEP COMPLETE!")
    print("="*60)
    print("\nView all experiments at: http://localhost:5000")
    print("Compare runs to find the best hyperparameters!")

if __name__ == "__main__":
    run_experiment_sweep()
</code></pre>
<p>This script:</p>
<ol>
<li><p><strong>Connects to MLflow</strong>: <code>mlflow.set_tracking_uri("</code><a href="http://localhost:5000"><code>http://localhost:5000</code></a><code>")</code></p>
</li>
<li><p><strong>Creates an experiment</strong>: <code>mlflow.set_experiment("fraud-detection")</code></p>
</li>
<li><p><strong>Logs parameters</strong>: All hyperparameters and data info</p>
</li>
<li><p><strong>Logs metrics</strong>: Accuracy, precision, recall, F1, ROC-AUC for both train and test sets</p>
</li>
<li><p><strong>Logs the model</strong>: Saves the trained model as an artifact</p>
</li>
<li><p><strong>Registers the model</strong>: Adds it to the Model Registry with automatic versioning</p>
</li>
</ol>
<p>Run the experiment sweep:</p>
<pre><code class="language-python">python src/train_mlflow.py
</code></pre>
<p>You'll see output for each experiment:</p>
<pre><code class="language-python">============================================================
RUNNING HYPERPARAMETER EXPERIMENT SWEEP
============================================================

--- Experiment 1/5 ---
Loading data...
Training model: n_estimators=50, max_depth=5
  TRAIN - Accuracy: 0.9821, F1: 0.6545, ROC-AUC: 0.9234
  TEST - Accuracy: 0.9795, F1: 0.5714, ROC-AUC: 0.8956

Registering model in MLflow Model Registry...
MLflow Run ID: abc123...

--- Experiment 5/5 ---
Training model: n_estimators=200, max_depth=20
  TRAIN - Accuracy: 0.9856, F1: 0.7123, ROC-AUC: 0.9567
  TEST - Accuracy: 0.9810, F1: 0.6667, ROC-AUC: 0.9234

============================================================
EXPERIMENT SWEEP COMPLETE!
============================================================
</code></pre>
<p>All 5 runs are now logged to MLflow with full metrics comparison available in the UI.</p>
<p>Now refresh the MLflow UI at <code>http://localhost:5000</code>. You'll see:</p>
<ol>
<li><p><strong>Experiments tab</strong>: Shows the "fraud-detection" experiment with 5 runs</p>
</li>
<li><p><strong>Each run</strong>: Shows parameters, metrics, and artifacts</p>
</li>
<li><p><strong>Compare</strong>: You can select multiple runs and compare them side-by-side</p>
</li>
<li><p><strong>Models tab</strong>: Shows "fraud-detection-model" with 5 versions</p>
</li>
</ol>
<p><strong>MLflow Tracking UI: Compare runs, metrics, and models at a glance</strong></p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771396202929/c5a7d547-31b6-4783-acea-f4e9433d81ef.png" alt="c5a7d547-31b6-4783-acea-f4e9433d81ef" style="display:block;margin:0 auto" width="1971" height="503" loading="lazy">

<h3 id="heading-33-how-to-use-the-model-registry"><strong>3.3</strong> How to Use the Model Registry</h3>
<p>The <strong>Model Registry</strong> provides a central hub for managing model versions and their lifecycle stages.</p>
<p>In the MLflow UI:</p>
<ol>
<li><p>Click the <strong>"Models"</strong> tab in the top navigation</p>
</li>
<li><p>Click <strong>"fraud-detection-model"</strong></p>
</li>
<li><p>You'll see all 5 versions listed with their metrics</p>
</li>
</ol>
<p><strong>Model Aliases:</strong> MLflow now uses <strong>aliases</strong> instead of stages. If you've seen older tutorials using "Staging" and "Production" stages, aliases are the newer, more flexible approach.</p>
<ul>
<li><p><strong>@champion</strong>: The production model serving live traffic</p>
</li>
<li><p><strong>@challenger</strong>: Candidate model being tested</p>
</li>
<li><p>You can create custom aliases like @baseline, @latest and so on.</p>
</li>
</ul>
<p><strong>Assign an alias:</strong></p>
<ol>
<li><p>Open MLflow UI → Models → fraud-detection-model</p>
</li>
<li><p>Click on the version you want to promote</p>
</li>
<li><p>Click <strong>"Add Alias"</strong></p>
</li>
<li><p>Enter <code>champion</code> and save</p>
</li>
</ol>
<p>Now you've assigned the <code>@champion</code> alias to your best model. Your API will load whichever version has this alias, making rollbacks as simple as moving the alias to a different version.</p>
<p><strong>Figure 3: MLflow Model Lifecycle — From Training to Production</strong></p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771396081377/da67d89f-b82d-4189-8150-ecc142ed198a.png" alt="Diagram showing the MLflow model lifecycle for a fraud detection system: a model is trained with experiment parameters, logged to MLflow tracking with metrics and artifacts, registered in the model registry as multiple versions, assigned aliases such as champion and challenger, and served in production by loading the model through the champion alias. The diagram also shows rollback by moving the alias to an earlier version and restarting the API." style="display:block;margin:0 auto" width="2083" height="1164" loading="lazy">

<h3 id="heading-34-update-api-to-load-from-registry"><strong>3.4 Update API to Load from Registry</strong></h3>
<p>Now let's update our API to load the champion model from the MLflow Registry instead of a pickle file. Create <code>src/serve_mlflow.py</code>:</p>
<pre><code class="language-python"># src/serve_mlflow.py
"""
Serve fraud detection model from MLflow Model Registry.

This version loads the @champion model from MLflow, which means:
- Always serves the latest @champion model
- Can roll back by changing the @champion alias
- No manual file copying needed
"""
import mlflow
import mlflow.sklearn
import pickle
import os
from fastapi import FastAPI
from pydantic import BaseModel, Field

# Configure MLflow
mlflow.set_tracking_uri("http://localhost:5000")

print("Loading model from MLflow Model Registry...")

# Load the champion model from the registry
# This automatically gets whichever version has the @champion alias
try:
    model = mlflow.sklearn.load_model("models:/fraud-detection-model@champion")
    print("Successfully loaded champion model from MLflow!")
except Exception as e:
    print(f"Error loading from MLflow: {e}")
    print("Make sure you've assigned the @champion alias to a model in the MLflow UI")
    raise

# Load the encoder (saved as an artifact)
# In a real system, you might also version this in MLflow
with open("encoder.pkl", "rb") as f:
    encoder = pickle.load(f)
print("Encoder loaded successfully!")

app = FastAPI(
    title="Fraud Detection API (MLflow)",
    description="""
    Fraud detection API that loads models from MLflow Model Registry.
    
    This version always serves the model with the @champion alias.
    To update the model:
    1. Train a new model with train_mlflow.py
    2. Compare metrics in MLflow UI
    3. Promote the best model to Production
    4. Restart this API
    
    To roll back: Move the @champion alias to a previous version in MLflow UI.
    """,
    version="2.0.0"
)

class Transaction(BaseModel):
    amount: float = Field(..., description="Transaction amount in dollars", example=150.00)
    hour: int = Field(..., description="Hour of the day (0-23)", example=14)
    day_of_week: int = Field(..., description="Day of week (0=Monday, 6=Sunday)", example=3)
    merchant_category: str = Field(..., description="Type of merchant", example="online")

class PredictionResponse(BaseModel):
    is_fraud: bool
    fraud_probability: float
    model_source: str = "MLflow Production"

@app.post("/predict", response_model=PredictionResponse)
def predict(tx: Transaction):
    """Predict whether a transaction is fraudulent using the champion model."""
    data = tx.dict()
    
    try:
        data["merchant_encoded"] = encoder.transform([data["merchant_category"]])[0]
    except ValueError:
        data["merchant_encoded"] = 0
    
    X = [[data["amount"], data["hour"], data["day_of_week"], data["merchant_encoded"]]]
    
    pred = model.predict(X)[0]
    prob = model.predict_proba(X)[0][1]
    
    return PredictionResponse(
        is_fraud=bool(pred),
        fraud_probability=round(float(prob), 4),
        model_source="MLflow Production"
    )

@app.get("/health")
def health():
    return {"status": "healthy", "model_source": "MLflow Registry"}

@app.get("/model-info")
def model_info():
    """Get information about the currently loaded model."""
    return {
        "registry": "MLflow",
        "model_name": "fraud-detection-model",
        "alias": "champion",
        "tracking_uri": "http://localhost:5000"
    }
</code></pre>
<p>Stop your old API (Ctrl+C) and start this new one:</p>
<pre><code class="language-python">uvicorn src.serve_mlflow:app --reload --host 0.0.0.0 --port 8000
</code></pre>
<p>Now deploying a new model is a <strong>controlled, auditable process</strong>:</p>
<ol>
<li><p><strong>Train new model</strong> → Automatically registered as new version</p>
</li>
<li><p><strong>Compare metrics</strong> → Use MLflow UI to compare with current Production</p>
</li>
<li><p><strong>Set as champion</strong> → Assign @champion alias in MLflow UI</p>
</li>
<li><p><strong>Restart API</strong> → Loads new Production model</p>
</li>
<li><p><strong>Roll back if needed</strong> → Move @champion alias to previous version</p>
</li>
</ol>
<p><strong>Checkpoint:</strong></p>
<ul>
<li><p>MLflow UI (<code>http://localhost:5000</code>) should show the "fraud-detection" experiment with 5 runs</p>
</li>
<li><p>The "Models" tab should show "fraud-detection-model" with 5 versions</p>
</li>
<li><p>One version should have @champion alias</p>
</li>
<li><p>The API should load and serve @champion model</p>
</li>
</ul>
<h2 id="heading-4-ensure-feature-consistency-with-feast"><strong>4. Ensure Feature Consistency with Feast</strong></h2>
<p>⚠️ <strong>First time hearing about feature stores?</strong> Don't worry.<br>You don't need to master every Feast detail on the first read.<br>Focus on <em>why</em> feature consistency matters — you can revisit the implementation later.<br><strong>Key takeaway:</strong> Training and serving must compute features the same way, or your model silently fails.</p>
<p><strong>What breaks without this:</strong> Your model sees different feature values in production than it saw during training. Accuracy drops silently. This is called "training-serving skew" and it's one of the most common causes of ML system failures.</p>
<p>One subtle but critical issue in ML systems is <strong>training-serving skew</strong> – when data transformations at training time differ from inference time. Even small discrepancies can severely degrade performance.</p>
<p><strong>Why This Matters:</strong> Imagine you're computing "average transaction amount per merchant category" as a feature. During training, you compute it using pandas in a notebook. During serving, you compute it using SQL in a different system. Small differences in how these computations handle edge cases (nulls, rounding, time windows) cause the model to see different features in production than it was trained on.</p>
<p>The result? <strong>Silent failures</strong> where accuracy drops but nothing errors out. Your model is making predictions based on features it's never seen before, and you have no idea.</p>
<p>In our naive implementation, we did handle one simple case: we saved the <code>LabelEncoder</code> to ensure <code>merchant_category</code> is encoded the same way in training and serving. But imagine if we had more complex feature engineering:</p>
<ul>
<li><p>Rolling averages over time windows</p>
</li>
<li><p>User-level aggregations</p>
</li>
<li><p>Cross-feature interactions</p>
</li>
<li><p>Real-time features from streaming data</p>
</li>
</ul>
<p>Maintaining consistency manually becomes impossible.</p>
<h3 id="heading-41-what-is-feast-and-why-use-it"><strong>4.1 What is Feast and Why Use It?</strong></h3>
<p>In production ML platforms, teams use a <strong>feature store</strong> to guarantee feature consistency between training and serving. <strong>Feast</strong> is one popular open-source option.</p>
<p>In this tutorial, we use Feast not because you <em>must</em>, but because it makes the training-serving contract explicit and teachable. The principles apply whether you use Feast, Tecton, Featureform, or a custom solution.</p>
<p>Feast provides:</p>
<table>
<thead>
<tr>
<th><strong>Capability</strong></th>
<th><strong>Description</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>Single source of truth</strong></td>
<td>Define features once, use everywhere</td>
</tr>
<tr>
<td><strong>Offline/online consistency</strong></td>
<td>Same features for training and serving</td>
</tr>
<tr>
<td><strong>Point-in-time correctness</strong></td>
<td>Prevents data leakage in training</td>
</tr>
<tr>
<td><strong>Low-latency serving</strong></td>
<td>Millisecond feature retrieval</td>
</tr>
<tr>
<td><strong>Feature versioning</strong></td>
<td>Track changes to feature definitions</td>
</tr>
</tbody></table>
<p><strong>How Feast works:</strong></p>
<ol>
<li><p><strong>Define features</strong> in Python code (feature definitions)</p>
</li>
<li><p><strong>Materialize features</strong> from your data sources to the online store</p>
</li>
<li><p><strong>Retrieve features</strong> using the same API for both training (offline) and serving (online)</p>
</li>
</ol>
<p>This ensures that training and serving use <strong>exactly the same feature computation logic</strong>.</p>
<h3 id="heading-42-install-and-initialize-feast"><strong>4.2 Install and Initialize Feast</strong></h3>
<p>We already installed Feast via requirements.txt. Now let's initialize a feature repository.</p>
<pre><code class="language-python"># Navigate to the feature_repo directory
cd feature_repo

# Initialize Feast (this creates template files)
feast init . --minimal

# Go back to project root
cd ..
</code></pre>
<p>This creates the basic Feast structure:</p>
<pre><code class="language-python">feature_repo/
├── feature_store.yaml    # Feast configuration
└── __init__.py
</code></pre>
<h3 id="heading-43-define-feature-definitions"><strong>4.3 Define Feature Definitions</strong></h3>
<p>First, let's create the Feast configuration file:</p>
<pre><code class="language-python"># feature_repo/feature_store.yaml
project: fraud_detection
registry: ../data/registry.db
provider: local
online_store:
  type: sqlite
  path: ../data/online_store.db
offline_store:
  type: file
entity_key_serialization_version: 3
</code></pre>
<p>This configuration:</p>
<ul>
<li><p>Names our project "fraud_detection"</p>
</li>
<li><p>Uses SQLite for the online store (for production, you'd use Redis or DynamoDB)</p>
</li>
<li><p>Uses local files for the offline store (for production, you'd use BigQuery or Snowflake)</p>
</li>
</ul>
<p>Now create the feature definitions:</p>
<pre><code class="language-python"># feature_repo/features.py
"""
Feast feature definitions for fraud detection.

This file defines:
- Entities: The keys we use to look up features (merchant_category)
- Data Sources: Where the raw feature data comes from (Parquet file)
- Feature Views: The features themselves and their schemas

The key insight: These definitions are the SINGLE SOURCE OF TRUTH.
Both training and serving use these exact definitions.
"""
from datetime import timedelta
from feast import Entity, FeatureView, Field, FileSource, ValueType
from feast.types import Float32, Int64

# =============================================================================
# ENTITIES
# =============================================================================
# An entity is the "key" we use to look up features.
# For merchant-level features, the entity is merchant_category.

merchant = Entity(
    name="merchant_category",
    description="Merchant category for the transaction (for example, 'online', 'grocery')",
    value_type=ValueType.STRING,
)

# =============================================================================
# DATA SOURCES
# =============================================================================
# Data sources tell Feast where to find the raw feature data.
# For local development, we use a Parquet file.
# For production, this could be BigQuery, Snowflake, S3, etc.

merchant_stats_source = FileSource(
    name="merchant_stats_source",
    path="../data/merchant_features.parquet",  # We'll create this file
    timestamp_field="event_timestamp",       # Required for point-in-time joins
)

# =============================================================================
# FEATURE VIEWS
# =============================================================================
# A Feature View defines a group of related features.
# It specifies:
# - Which entity the features are for
# - The schema (names and types of features)
# - Where the data comes from
# - How long features are valid (TTL)

merchant_stats_fv = FeatureView(
    name="merchant_stats",
    description="Aggregated statistics per merchant category",
    entities=[merchant],
    ttl=timedelta(days=7),  # Features are valid for 7 days
    schema=[
        Field(name="avg_amount", dtype=Float32, description="Average transaction amount"),
        Field(name="transaction_count", dtype=Int64, description="Number of transactions"),
        Field(name="fraud_rate", dtype=Float32, description="Historical fraud rate"),
    ],
    source=merchant_stats_source,
    online=True,  # Enable online serving (low-latency retrieval)
)
</code></pre>
<h3 id="heading-44-materialize-features-to-online-store"><strong>4.4 Materialize Features to Online Store</strong></h3>
<p>Now we need to:</p>
<ol>
<li><p>Compute the features from our training data</p>
</li>
<li><p>Save them in a format Feast can read</p>
</li>
<li><p>Apply the Feast definitions</p>
</li>
<li><p>Materialize features to the online store</p>
</li>
</ol>
<p>Create <code>src/prepare_feast_features.py</code>:</p>
<pre><code class="language-python"># src/prepare_feast_features.py
"""
Prepare feature data for Feast.

This script:
1. Computes aggregated merchant features from training data
2. Saves them in Parquet format (Feast's offline store format)
3. Applies Feast feature definitions
4. Materializes features to the online store for low-latency serving

Run this whenever your training data changes or you want to refresh features.
"""
import pandas as pd
import numpy as np
from datetime import datetime
import subprocess
import os

def compute_merchant_features(df: pd.DataFrame) -&gt; pd.DataFrame:
    """
    Compute aggregated features by merchant category.
    
    THIS IS THE SINGLE SOURCE OF TRUTH FOR FEATURE COMPUTATION.
    
    Both training and serving will use features computed by this exact logic.
    Any change here automatically applies everywhere.
    
    Args:
        df: Transaction DataFrame with columns: amount, merchant_category, is_fraud
        
    Returns:
        DataFrame with computed features per merchant category
    """
    print("Computing merchant-level features...")
    
    # Group by merchant category and compute aggregates
    stats = df.groupby('merchant_category').agg({
        'amount': ['mean', 'count'],
        'is_fraud': 'mean'
    }).reset_index()
    
    # Flatten column names
    stats.columns = ['merchant_category', 'avg_amount', 'transaction_count', 'fraud_rate']
    
    # Add timestamp for Feast (required for point-in-time correct joins)
    stats['event_timestamp'] = datetime.now()
    
    # Convert types to match Feast schema
    stats['avg_amount'] = stats['avg_amount'].astype('float32')
    stats['transaction_count'] = stats['transaction_count'].astype('int64')
    stats['fraud_rate'] = stats['fraud_rate'].astype('float32')
    
    return stats

def main():
    print("="*60)
    print("FEAST FEATURE PREPARATION")
    print("="*60)
    
    # Load training data
    print("\n1. Loading training data...")
    train_df = pd.read_csv('data/train.csv')
    print(f"   Loaded {len(train_df):,} transactions")
    
    # Compute merchant features
    print("\n2. Computing merchant features...")
    merchant_features = compute_merchant_features(train_df)
    
    print("\n   Computed features:")
    print(merchant_features.to_string(index=False))
    
    # Save as Parquet (required format for Feast file source)
    print("\n3. Saving features to Parquet...")
    os.makedirs('data', exist_ok=True)
    output_path = 'data/merchant_features.parquet'
    merchant_features.to_parquet(output_path, index=False)
    print(f"   Saved to {output_path}")
    
    # Apply Feast feature definitions
    print("\n4. Applying Feast feature definitions...")
    try:
        result = subprocess.run(
            ['feast', 'apply'],
            cwd='feature_repo',
            capture_output=True,
            text=True,
            check=True
        )
        print("   Feature definitions applied successfully!")
        if result.stdout:
            print(f"   {result.stdout}")
    except subprocess.CalledProcessError as e:
        print(f"   Error applying Feast: {e.stderr}")
        raise
    
    # Materialize features to online store
    print("\n5. Materializing features to online store...")
    try:
        result = subprocess.run(
            ['feast', 'materialize-incremental', datetime.now().isoformat()],
            cwd='feature_repo',
            capture_output=True,
            text=True,
            check=True
        )
        print("   Features materialized successfully!")
        if result.stdout:
            print(f"   {result.stdout}")
    except subprocess.CalledProcessError as e:
        print(f"   Error materializing: {e.stderr}")
        raise
    
    print("\n" + "="*60)
    print("FEAST FEATURE PREPARATION COMPLETE!")
    print("="*60)
    print("\nYou can now:")
    print("  - Retrieve features for training: get_training_features()")
    print("  - Retrieve features for serving: get_online_features()")
    print("  - View feature stats: feast feature-views list")

if __name__ == "__main__":
    main()
</code></pre>
<p>Run the feature preparation:</p>
<pre><code class="language-python">python src/prepare_feast_features.py
</code></pre>
<p>You should see:</p>
<pre><code class="language-python">============================================================
FEAST FEATURE PREPARATION
============================================================

1. Loading training data... 8,000 transactions
2. Computing merchant features...
   grocery: avg=$31.24, fraud_rate=0.85%
   online: avg=$98.45, fraud_rate=4.87%
   restaurant: avg=$28.12, fraud_rate=0.50%
   retail: avg=$45.67, fraud_rate=1.02%
   travel: avg=$156.23, fraud_rate=4.18%
3. Saving to data/merchant_features.parquet ✓
4. Applying Feast definitions... ✓
5. Materializing to online store... ✓

FEAST FEATURE PREPARATION COMPLETE!
</code></pre>
<h3 id="heading-45-retrieve-features-for-training-and-serving"><strong>4.5 Retrieve Features for Training and Serving</strong></h3>
<p>Now let's create utilities to retrieve features consistently for both training and serving:</p>
<pre><code class="language-python"># src/feast_features.py
"""
Feast feature retrieval for training and serving.

This module provides functions to retrieve features from Feast:
- get_training_features(): For offline training (historical features)
- get_online_features(): For real-time serving (low-latency)

IMPORTANT: Both functions use the SAME feature definitions,
ensuring consistency between training and serving.
"""
import pandas as pd
from feast import FeatureStore
from datetime import datetime

# Initialize Feast store (points to our feature_repo)
store = FeatureStore(repo_path="feature_repo")

def get_training_features(df: pd.DataFrame) -&gt; pd.DataFrame:
    """
    Get features for training using Feast's offline store.
    
    Uses point-in-time correct joins to prevent data leakage.
    This means features are looked up as of the time each transaction occurred,
    not as of "now" - preventing you from accidentally using future data.
    
    Args:
        df: DataFrame with at least 'merchant_category' column
        
    Returns:
        DataFrame with original columns plus Feast features
    """
    print("Retrieving training features from Feast offline store...")
    
    # Prepare entity dataframe with timestamps
    # Each row needs: entity key(s) + event_timestamp
    entity_df = df[['merchant_category']].copy()
    entity_df['event_timestamp'] = datetime.now()  # See note below
    entity_df = entity_df.drop_duplicates()
    
    # ⚠️ Simplification: For clarity, we use the current timestamp here.
    # In real systems, this would be the actual event time of each transaction.
    
    # Retrieve historical features
    # Feast handles the point-in-time join automatically
    training_data = store.get_historical_features(
        entity_df=entity_df,
        features=[
            "merchant_stats:avg_amount",
            "merchant_stats:transaction_count",
            "merchant_stats:fraud_rate",
        ],
    ).to_df()
    
    # Merge features back with original dataframe
    result = df.merge(
        training_data[['merchant_category', 'avg_amount', 'transaction_count', 'fraud_rate']],
        on='merchant_category',
        how='left'
    )
    
    print(f"Retrieved features for {len(entity_df)} unique merchants")
    return result

def get_online_features(merchant_category: str) -&gt; dict:
    """
    Get features for real-time serving using Feast's online store.
    
    This is optimized for low-latency retrieval (milliseconds).
    Use this in your prediction API for real-time inference.
    
    Args:
        merchant_category: The merchant category to look up
        
    Returns:
        Dictionary with feature names and values
    """
    # Retrieve from online store (low-latency)
    feature_vector = store.get_online_features(
        features=[
            "merchant_stats:avg_amount",
            "merchant_stats:transaction_count",
            "merchant_stats:fraud_rate",
        ],
        entity_rows=[{"merchant_category": merchant_category}],
    ).to_dict()
    
    # Format the response
    return {
        'merchant_avg_amount': feature_vector['avg_amount'][0],
        'merchant_tx_count': feature_vector['transaction_count'][0],
        'merchant_fraud_rate': feature_vector['fraud_rate'][0],
    }

def get_online_features_batch(merchant_categories: list) -&gt; pd.DataFrame:
    """
    Get features for multiple merchants at once (batch serving).
    
    More efficient than calling get_online_features() in a loop.
    
    Args:
        merchant_categories: List of merchant categories to look up
        
    Returns:
        DataFrame with features for each merchant
    """
    feature_vector = store.get_online_features(
        features=[
            "merchant_stats:avg_amount",
            "merchant_stats:transaction_count",
            "merchant_stats:fraud_rate",
        ],
        entity_rows=[{"merchant_category": mc} for mc in merchant_categories],
    ).to_df()
    
    return feature_vector

if __name__ == "__main__":
    # Test the feature retrieval functions
    print("="*60)
    print("TESTING FEAST FEATURE RETRIEVAL")
    print("="*60)
    
    # Test offline retrieval (for training)
    print("\n1. Testing OFFLINE feature retrieval (for training)...")
    train_df = pd.read_csv('data/train.csv').head(10)
    enriched = get_training_features(train_df)
    print("\n   Sample enriched training data:")
    print(enriched[['amount', 'merchant_category', 'avg_amount', 'fraud_rate']].head())
    
    # Test online retrieval (for serving)
    print("\n2. Testing ONLINE feature retrieval (for serving)...")
    for category in ['online', 'grocery', 'travel', 'restaurant', 'retail']:
        features = get_online_features(category)
        print(f"   {category}: avg_amount=${features['merchant_avg_amount']:.2f}, "
              f"fraud_rate={features['merchant_fraud_rate']:.2%}")
    
    # Test batch retrieval
    print("\n3. Testing BATCH online retrieval...")
    batch_features = get_online_features_batch(['online', 'grocery', 'travel'])
    print(batch_features)
    
    print("\n" + "="*60)
    print("FEAST FEATURE RETRIEVAL TEST COMPLETE!")
    print("="*60)
</code></pre>
<p>Test the feature retrieval:</p>
<pre><code class="language-python">python src/feast_features.py
</code></pre>
<p>You should see:</p>
<pre><code class="language-python">============================================================
TESTING FEAST FEATURE RETRIEVAL
============================================================

1. Testing OFFLINE feature retrieval (for training)...
Retrieving training features from Feast offline store...
Retrieved features for 5 unique merchants

   Sample enriched training data:
   amount merchant_category  avg_amount  fraud_rate
    45.23           grocery       31.24      0.0085
   123.45            online       98.45      0.0487
    ...

2. Testing ONLINE feature retrieval (for serving)...
   online: avg_amount=$98.45, fraud_rate=4.87%
   grocery: avg_amount=$31.24, fraud_rate=0.85%
   travel: avg_amount=$156.23, fraud_rate=4.18%
   restaurant: avg_amount=$28.12, fraud_rate=0.50%
   retail: avg_amount=$45.67, fraud_rate=1.02%

3. Testing BATCH online retrieval...
  merchant_category  avg_amount  transaction_count  fraud_rate
               online       98.45               1234      0.0487
              grocery       31.24               2345      0.0085
               travel      156.23                478      0.0418
</code></pre>
<h3 id="heading-why-feast-over-custom-code"><strong>Why Feast Over Custom Code?</strong></h3>
<table>
<thead>
<tr>
<th><strong>Aspect</strong></th>
<th><strong>Custom Code</strong></th>
<th><strong>Feast</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>Consistency</strong></td>
<td>Manual effort to keep in sync</td>
<td>Automatic - same definitions everywhere</td>
</tr>
<tr>
<td><strong>Point-in-time correctness</strong></td>
<td>Must implement yourself</td>
<td>Built-in</td>
</tr>
<tr>
<td><strong>Online serving</strong></td>
<td>Must build your own cache</td>
<td>Built-in online store</td>
</tr>
<tr>
<td><strong>Feature versioning</strong></td>
<td>Not supported</td>
<td>Built-in</td>
</tr>
<tr>
<td><strong>Scalability</strong></td>
<td>Limited</td>
<td>Production-ready (BigQuery, Redis, etc.)</td>
</tr>
<tr>
<td><strong>Team collaboration</strong></td>
<td>Difficult</td>
<td>Feature registry with documentation</td>
</tr>
<tr>
<td><strong>Monitoring</strong></td>
<td>Manual</td>
<td>Built-in feature statistics</td>
</tr>
</tbody></table>
<p>💡 <strong>Mental Model</strong>: Treat feature definitions like database schemas.<br>You wouldn't compute a column one way in your application and a different way in your reports. Features deserve the same discipline — define once, use everywhere.</p>
<p><strong>Checkpoint:</strong> After running <code>prepare_feast_</code><a href="http://features.py"><code>features.py</code></a>, you should have:</p>
<ul>
<li><p><code>data/merchant_features.parquet</code> (computed features)</p>
</li>
<li><p><code>data/registry.db</code> (Feast registry)</p>
</li>
<li><p><code>data/online_store.db</code> (SQLite online store)</p>
</li>
</ul>
<p>Running <code>python src/feast_</code><a href="http://features.py"><code>features.py</code></a> should successfully retrieve features for all merchant categories.</p>
<h2 id="heading-5-add-data-validation-with-great-expectations"><strong>5. Add Data Validation with Great Expectations</strong></h2>
<p><strong>What breaks without this:</strong> Your API accepts garbage input (negative amounts, invalid hours) and returns meaningless predictions. Worse, you have no idea it happened.</p>
<p>Recall that our API currently trusts input blindly. We saw how garbage data produces a prediction with no warning. <strong>Great Expectations</strong> is an open-source tool for data quality testing – defining rules (expectations) and testing data against them.</p>
<p><strong>Why This Matters:</strong> Data validation acts as a gatekeeper. Bad data is rejected <strong>before</strong> it can harm predictions. As the saying goes, "Garbage in, garbage out" – feeding unreliable data yields unreliable results. With validation, we transform this to "Garbage in, <strong>error out</strong>" – much better for debugging and reliability.</p>
<h3 id="heading-51-define-expectations"><strong>5.1 Define Expectations</strong></h3>
<p>What are reasonable expectations for our transaction data? Based on domain knowledge:</p>
<table>
<thead>
<tr>
<th><strong>Field</strong></th>
<th><strong>Expectation</strong></th>
<th><strong>Reason</strong></th>
</tr>
</thead>
<tbody><tr>
<td><code>amount</code></td>
<td>Positive (&gt; 0)</td>
<td>Negative transactions don't make sense</td>
</tr>
<tr>
<td><code>amount</code></td>
<td>Below $50,000</td>
<td>Extremely large amounts are outliers/errors</td>
</tr>
<tr>
<td><code>hour</code></td>
<td>0-23 inclusive</td>
<td>Valid hours in a day</td>
</tr>
<tr>
<td><code>day_of_week</code></td>
<td>0-6 inclusive</td>
<td>Valid days (Mon=0, Sun=6)</td>
</tr>
<tr>
<td><code>merchant_category</code></td>
<td>One of known categories</td>
<td>Must match training data</td>
</tr>
<tr>
<td>All fields</td>
<td>Not null</td>
<td>Required for prediction</td>
</tr>
</tbody></table>
<p>Create <code>src/data_validation.py</code>:</p>
<pre><code class="language-python"># src/data_validation.py
"""
Data validation for fraud detection.

This module provides functions to validate input data BEFORE making predictions.
Invalid data is rejected with clear error messages.

The key insight: It's better to reject bad input than to make garbage predictions.
"""
import pandas as pd
from typing import Dict, List, Any, Optional

# Define the valid merchant categories (must match training data!)
VALID_CATEGORIES = ["grocery", "restaurant", "retail", "online", "travel"]

def validate_transaction(data: Dict[str, Any]) -&gt; Dict[str, Any]:
    """
    Validate a single transaction for fraud prediction.
    
    Checks all business rules and data quality requirements.
    Returns a dictionary with 'valid' (bool) and 'errors' (list).
    
    Args:
        data: Dictionary with transaction fields
        
    Returns:
        {"valid": bool, "errors": list of error messages}
        
    Example:
        &gt;&gt;&gt; validate_transaction({"amount": -100, "hour": 25, ...})
        {"valid": False, "errors": ["amount must be positive", "hour must be 0-23"]}
    """
    errors = []
    
    # ==========================================================================
    # Amount Validation
    # ==========================================================================
    amount = data.get("amount")
    if amount is None:
        errors.append("amount is required")
    elif not isinstance(amount, (int, float)):
        errors.append(f"amount must be a number (got {type(amount).__name__})")
    elif amount &lt;= 0:
        errors.append("amount must be positive")
    elif amount &gt; 50000:
        errors.append(f"amount exceeds maximum allowed value of \(50,000 (got \){amount:,.2f})")
    
    # ==========================================================================
    # Hour Validation
    # ==========================================================================
    hour = data.get("hour")
    if hour is None:
        errors.append("hour is required")
    elif not isinstance(hour, int):
        errors.append(f"hour must be an integer (got {type(hour).__name__})")
    elif not (0 &lt;= hour &lt;= 23):
        errors.append(f"hour must be between 0 and 23 (got {hour})")
    
    # ==========================================================================
    # Day of Week Validation
    # ==========================================================================
    day = data.get("day_of_week")
    if day is None:
        errors.append("day_of_week is required")
    elif not isinstance(day, int):
        errors.append(f"day_of_week must be an integer (got {type(day).__name__})")
    elif not (0 &lt;= day &lt;= 6):
        errors.append(f"day_of_week must be between 0 (Monday) and 6 (Sunday) (got {day})")
    
    # ==========================================================================
    # Merchant Category Validation
    # ==========================================================================
    category = data.get("merchant_category")
    if category is None:
        errors.append("merchant_category is required")
    elif not isinstance(category, str):
        errors.append(f"merchant_category must be a string (got {type(category).__name__})")
    elif category not in VALID_CATEGORIES:
        errors.append(
            f"merchant_category must be one of {VALID_CATEGORIES} (got '{category}')"
        )
    
    return {
        "valid": len(errors) == 0,
        "errors": errors
    }

def validate_batch(df: pd.DataFrame) -&gt; Dict[str, Any]:
    """
    Validate a batch of transactions using Great Expectations.
    
    This is useful for validating training data or batch prediction requests.
    Uses Great Expectations for more sophisticated validation.
    
    Args:
        df: DataFrame with transaction data
        
    Returns:
        Dictionary with validation results
    """
    import great_expectations as gx
    
    # Convert to Great Expectations dataset
    ge_df = gx.from_pandas(df)
    
    results = []
    
    # Amount expectations
    r = ge_df.expect_column_values_to_be_between(
        'amount', min_value=0.01, max_value=50000, mostly=0.99
    )
    results.append(('amount_range', r.success, r.result))
    
    # Hour expectations
    r = ge_df.expect_column_values_to_be_between(
        'hour', min_value=0, max_value=23
    )
    results.append(('hour_range', r.success, r.result))
    
    # Day of week expectations
    r = ge_df.expect_column_values_to_be_between(
        'day_of_week', min_value=0, max_value=6
    )
    results.append(('day_range', r.success, r.result))
    
    # Merchant category expectations
    r = ge_df.expect_column_values_to_be_in_set(
        'merchant_category', VALID_CATEGORIES
    )
    results.append(('category_valid', r.success, r.result))
    
    # No nulls in critical fields
    for col in ['amount', 'hour', 'day_of_week', 'merchant_category']:
        r = ge_df.expect_column_values_to_not_be_null(col)
        results.append((f'{col}_not_null', r.success, r.result))
    
    # Summarize results
    passed = sum(1 for _, success, _ in results if success)
    total = len(results)
    
    return {
        'success': passed == total,
        'passed': passed,
        'total': total,
        'pass_rate': passed / total,
        'details': {name: {'passed': success, 'result': result} 
                   for name, success, result in results}
    }

if __name__ == "__main__":
    print("="*60)
    print("TESTING DATA VALIDATION")
    print("="*60)
    
    # Test single transaction validation
    print("\n1. Single Transaction Validation")
    print("-"*40)
    
    test_cases = [
        {
            "name": "Valid transaction",
            "data": {"amount": 50.0, "hour": 14, "day_of_week": 3, "merchant_category": "grocery"}
        },
        {
            "name": "Negative amount",
            "data": {"amount": -100.0, "hour": 14, "day_of_week": 3, "merchant_category": "grocery"}
        },
        {
            "name": "Invalid hour",
            "data": {"amount": 50.0, "hour": 25, "day_of_week": 3, "merchant_category": "grocery"}
        },
        {
            "name": "Unknown merchant",
            "data": {"amount": 50.0, "hour": 14, "day_of_week": 3, "merchant_category": "unknown"}
        },
        {
            "name": "Everything wrong",
            "data": {"amount": -999, "hour": 99, "day_of_week": 15, "merchant_category": "fake"}
        },
    ]
    
    for tc in test_cases:
        result = validate_transaction(tc["data"])
        status = "PASS" if result["valid"] else "FAIL"
        print(f"\n{tc['name']}: {status}")
        if result["errors"]:
            for error in result["errors"]:
                print(f"  - {error}")
    
    # Test batch validation
    print("\n\n2. Batch Validation with Great Expectations")
    print("-"*40)
    
    train_df = pd.read_csv('data/train.csv')
    results = validate_batch(train_df)
    
    print(f"\nTraining data validation: {results['passed']}/{results['total']} checks passed")
    print(f"Pass rate: {results['pass_rate']:.1%}")
    
    if not results['success']:
        print("\nFailed checks:")
        for name, detail in results['details'].items():
            if not detail['passed']:
                print(f"  - {name}")
</code></pre>
<h3 id="heading-when-to-use-which-validation-approach"><strong>When to Use Which Validation Approach</strong></h3>
<table>
<thead>
<tr>
<th><strong>Approach</strong></th>
<th><strong>Use Case</strong></th>
<th><strong>Latency</strong></th>
<th><strong>When to Use</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>Custom Python</strong> (<code>validate_transaction</code>)</td>
<td>Real-time API requests</td>
<td>&lt;1ms</td>
<td>Every prediction request</td>
</tr>
<tr>
<td><strong>Great Expectations</strong></td>
<td>Batch data quality</td>
<td>Seconds</td>
<td>Training data, periodic audits, CI/CD</td>
</tr>
</tbody></table>
<p>We use <strong>both</strong> in this tutorial because they serve different purposes:</p>
<ul>
<li><p>Custom validation is your <strong>runtime gatekeeper</strong> — fast enough for every request</p>
</li>
<li><p>Great Expectations is your <strong>batch auditor</strong> — thorough checks on datasets</p>
</li>
</ul>
<h3 id="heading-52-integrate-validation-into-fastapi"><strong>5.2 Integrate Validation into FastAPI</strong></h3>
<p>Now let's update our API to reject invalid input with clear error messages:</p>
<pre><code class="language-python"># src/serve_validated.py
"""
Serve fraud detection model with input validation.

This version adds data validation BEFORE making predictions:
- Invalid inputs are rejected with HTTP 400 and clear error messages
- Valid inputs are processed and predictions returned

This is much safer than the naive version which accepted garbage.
"""
import pickle
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from src.data_validation import validate_transaction

# Load model
with open("models/model.pkl", "rb") as f:
    model, encoder = pickle.load(f)

app = FastAPI(
    title="Fraud Detection API (Validated)",
    description="""
    Fraud detection API with input validation.
    
    All inputs are validated before prediction:
    - amount: Must be positive and below $50,000
    - hour: Must be 0-23
    - day_of_week: Must be 0-6
    - merchant_category: Must be one of: grocery, restaurant, retail, online, travel
    
    Invalid inputs return HTTP 400 with detailed error messages.
    """,
    version="3.0.0"
)

class Transaction(BaseModel):
    amount: float = Field(..., description="Transaction amount (must be positive)", example=150.00)
    hour: int = Field(..., description="Hour of day (0-23)", example=14)
    day_of_week: int = Field(..., description="Day of week (0=Mon, 6=Sun)", example=3)
    merchant_category: str = Field(..., description="Merchant type", example="online")

class PredictionResponse(BaseModel):
    is_fraud: bool
    fraud_probability: float
    validation_passed: bool = True

class ValidationErrorResponse(BaseModel):
    detail: dict

@app.post("/predict", response_model=PredictionResponse, responses={400: {"model": ValidationErrorResponse}})
def predict(tx: Transaction):
    """
    Predict whether a transaction is fraudulent.
    
    Input is validated before prediction. Invalid inputs return HTTP 400.
    """
    data = tx.dict()
    
    # VALIDATE INPUT BEFORE MAKING PREDICTION
    validation = validate_transaction(data)
    
    if not validation["valid"]:
        raise HTTPException(
            status_code=400,
            detail={
                "message": "Validation failed",
                "errors": validation["errors"],
                "input": data
            }
        )
    
    # Input is valid - make prediction
    data["merchant_encoded"] = encoder.transform([data["merchant_category"]])[0]
    X = [[data["amount"], data["hour"], data["day_of_week"], data["merchant_encoded"]]]
    
    pred = model.predict(X)[0]
    prob = model.predict_proba(X)[0][1]
    
    return PredictionResponse(
        is_fraud=bool(pred),
        fraud_probability=round(float(prob), 4),
        validation_passed=True
    )

@app.get("/health")
def health():
    return {"status": "healthy", "validation": "enabled"}
</code></pre>
<p>Start the validated API:</p>
<pre><code class="language-python">uvicorn src.serve_validated:app --reload --host 0.0.0.0 --port 8000
</code></pre>
<p>Now test with bad data:</p>
<pre><code class="language-python">curl -X POST "http://localhost:8000/predict" \
  -H "Content-Type: application/json" \
  -d '{"amount": -500, "hour": 25, "day_of_week": 10, "merchant_category": "fake"}'
</code></pre>
<p>Response (HTTP 400):</p>
<pre><code class="language-python">{
  "detail": {
    "message": "Validation failed",
    "errors": [
      "amount must be positive",
      "hour must be between 0 and 23 (got 25)",
      "day_of_week must be between 0 (Monday) and 6 (Sunday) (got 10)",
      "merchant_category must be one of ['grocery', 'restaurant', 'retail', 'online', 'travel'] (got 'fake')"
    ],
    "input": {"amount": -500, "hour": 25, "day_of_week": 10, "merchant_category": "fake"}
  }
}
</code></pre>
<p><strong>This is a huge improvement!</strong> Instead of silently accepting garbage and returning meaningless predictions, we now:</p>
<ul>
<li><p>Reject invalid input immediately</p>
</li>
<li><p>Provide clear, actionable error messages</p>
</li>
<li><p>Return the original input for debugging</p>
</li>
<li><p>Use proper HTTP status codes (400 for client error)</p>
</li>
</ul>
<p><strong>Checkpoint:</strong> Your validated API should:</p>
<ul>
<li><p>Accept valid transactions and return predictions</p>
</li>
<li><p>Reject invalid transactions with HTTP 400 and detailed error messages</p>
</li>
<li><p>Show validation errors for each invalid field</p>
</li>
</ul>
<h2 id="heading-6-monitor-model-performance-and-data-drift"><strong>6. Monitor Model Performance and Data Drift</strong></h2>
<p><strong>What breaks without this:</strong> Your model's accuracy drops from 98% to 70% over two months. Nobody notices until customers complain. By then, significant damage has occurred.</p>
<p>Even with a great model and clean input data, <strong>time can be an enemy</strong>. Model performance can decline as real-world data evolves – this is known as <strong>model drift</strong> or <strong>model decay</strong>.</p>
<p><strong>Why This Matters:</strong> In traditional software, you monitor CPU, memory, error rates, and response times. In ML, you must <strong>also</strong> monitor:</p>
<ul>
<li><p>Data quality (are inputs within expected ranges?)</p>
</li>
<li><p>Model performance (is accuracy holding up?)</p>
</li>
<li><p>Data drift (has input distribution changed?)</p>
</li>
<li><p>Prediction drift (has the distribution of predictions changed?)</p>
</li>
</ul>
<p>Without monitoring, your model could be silently failing for weeks before anyone notices. By then, significant damage may have occurred – fraud slipping through, good customers blocked, revenue lost.</p>
<h3 id="heading-61-the-four-pillars-of-ml-observability"><strong>6.1 The Four Pillars of ML Observability</strong></h3>
<table>
<thead>
<tr>
<th><strong>Pillar</strong></th>
<th><strong>What to Monitor</strong></th>
<th><strong>Why It Matters</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>Data Quality</strong></td>
<td>Are inputs valid? Nulls? Outliers?</td>
<td>Bad data causes bad predictions</td>
</tr>
<tr>
<td><strong>Model Performance</strong></td>
<td>Accuracy, precision, recall, F1</td>
<td>Is the model still working?</td>
</tr>
<tr>
<td><strong>Data Drift</strong></td>
<td>Has input distribution changed from training?</td>
<td>Model may not generalize to new data</td>
</tr>
<tr>
<td><strong>Prediction Drift</strong></td>
<td>Has prediction distribution changed?</td>
<td>May indicate data or concept drift</td>
</tr>
</tbody></table>
<h3 id="heading-62-build-a-drift-monitor-with-evidently"><strong>6.2 Build a Drift Monitor with Evidently</strong></h3>
<p><strong>Evidently</strong> is an open-source library specifically designed for ML monitoring. It can detect drift, generate reports, and integrate with monitoring systems.</p>
<p>Create <code>src/monitoring.py</code>:</p>
<pre><code class="language-python"># src/monitoring.py
"""
Model monitoring with Evidently.

This module provides tools to:
1. Detect data drift between training and production data
2. Generate detailed HTML reports
3. Track drift over time
4. Alert when drift exceeds thresholds

In production, you would run drift checks periodically (hourly, daily)
and alert when significant drift is detected.
"""
import pandas as pd
import numpy as np
from evidently.report import Report
from evidently.metric_preset import DataDriftPreset, TargetDriftPreset
from evidently.metrics import (
    DatasetDriftMetric,
    DataDriftTable,
    ColumnDriftMetric
)
from datetime import datetime
from typing import List, Dict, Any, Optional

class DriftMonitor:
    """
    Monitor for detecting data drift between reference (training) and current data.
    
    Implementation Note: We use two approaches here:
    1. Scipy's KS-test — A lightweight statistical method that works anywhere (our fallback)
    2. Evidently — A full-featured library with beautiful reports (our primary tool)
    
    The KS-test is included as defensive coding — if Evidently fails to generate 
    a report, we still get drift detection.
    
    Usage:
        monitor = DriftMonitor(training_data)
        result = monitor.check_drift(production_data)
        if result['drift_detected']:
            alert("Drift detected!")
    """
    
    def __init__(self, reference_data: pd.DataFrame, feature_columns: Optional[List[str]] = None):
        """
        Initialize the drift monitor with reference (training) data.
        
        Args:
            reference_data: The training data to compare against
            feature_columns: Columns to monitor (default: all numeric columns)
        """
        self.reference = reference_data
        self.feature_columns = feature_columns or reference_data.select_dtypes(
            include=[np.number]
        ).columns.tolist()
        self.history: List[Dict[str, Any]] = []
        
        print(f"Drift monitor initialized with {len(self.reference):,} reference samples")
        print(f"Monitoring columns: {self.feature_columns}")
    
    def check_drift(self, current_data: pd.DataFrame, threshold: float = 0.1) -&gt; Dict[str, Any]:
        """
        Check for drift between reference and current data.
        
        Args:
            current_data: Current/production data to check
            threshold: Drift share threshold for alerting (default 10%)
            
        Returns:
            Dictionary with drift results
        """
        from scipy import stats
        
        ref_subset = self.reference[self.feature_columns]
        cur_subset = current_data[self.feature_columns]
        
        # Simple statistical drift detection using KS test
        drifted_columns = []
        for col in self.feature_columns:
            statistic, p_value = stats.ks_2samp(
                ref_subset[col].dropna(),
                cur_subset[col].dropna()
            )
            if p_value &lt; 0.05:  # 5% significance level
                drifted_columns.append(col)
        
        n_features = len(self.feature_columns)
        n_drifted = len(drifted_columns)
        drift_share = n_drifted / n_features if n_features &gt; 0 else 0
        
        result = {
            'timestamp': datetime.now().isoformat(),
            'drift_detected': n_drifted &gt; 0,
            'drift_share': drift_share,
            'drifted_columns': drifted_columns,
            'n_features': n_features,
            'n_drifted': n_drifted,
            'current_samples': len(current_data),
            'threshold': threshold,
            'alert': drift_share &gt; threshold
        }
        
        self.history.append(result)
        
        return result
    
    def generate_report(self, current_data: pd.DataFrame, output_path: str = "drift_report.html"):
        """
        Generate a detailed HTML drift report using Evidently.
        
        Opens in browser for visual inspection of drift patterns.
        """
        ref_subset = self.reference[self.feature_columns]
        cur_subset = current_data[self.feature_columns]
        
        try:
            report = Report(metrics=[DataDriftPreset()])
            report.run(reference_data=ref_subset, current_data=cur_subset)
            
            # Save HTML report
            with open(output_path, 'w') as f:
                f.write(report.show(mode='inline').data)
            
            print(f"Drift report saved to {output_path}")
            print(f"Open this file in a browser to view detailed visualizations.")
        except Exception as e:
            print(f"Could not generate Evidently report: {e}")
            print(f"Using simplified drift detection instead.")
    
    def get_alerts(self, threshold: float = 0.1) -&gt; List[Dict[str, Any]]:
        """
        Get all alerts from history where drift exceeded threshold.
        """
        return [
            {
                'timestamp': r['timestamp'],
                'severity': 'HIGH' if r['drift_share'] &gt; 0.3 else 'MEDIUM',
                'drift_share': r['drift_share'],
                'message': f"Drift detected: {r['drift_share']:.1%} of features drifted",
                'drifted_columns': r['drifted_columns']
            }
            for r in self.history
            if r['drift_share'] &gt; threshold
        ]
    
    def summary(self) -&gt; Dict[str, Any]:
        """Get summary statistics from monitoring history."""
        if not self.history:
            return {"message": "No drift checks performed yet"}
        
        drift_shares = [r['drift_share'] for r in self.history]
        alerts = [r for r in self.history if r['alert']]
        
        return {
            'total_checks': len(self.history),
            'total_alerts': len(alerts),
            'avg_drift_share': np.mean(drift_shares),
            'max_drift_share': np.max(drift_shares),
            'first_check': self.history[0]['timestamp'],
            'last_check': self.history[-1]['timestamp']
        }


def simulate_drift_scenarios():
    """
    Demonstrate drift detection with different scenarios.
    
    This simulates what happens when production data differs from training data.
    """
    from src.generate_data import generate_transactions
    
    print("="*70)
    print("DRIFT DETECTION SIMULATION")
    print("="*70)
    
    # Load reference (training) data
    print("\n1. Loading reference data (training set)...")
    reference = pd.read_csv('data/train.csv')
    feature_cols = ['amount', 'hour', 'day_of_week']
    
    # Initialize drift monitor
    monitor = DriftMonitor(reference, feature_cols)
    
    # Scenario 1: Similar data (should show minimal drift)
    print("\n" + "-"*70)
    print("SCENARIO 1: Test data (similar distribution)")
    print("-"*70)
    test_data = pd.read_csv('data/test.csv')
    result = monitor.check_drift(test_data)
    print(f"  Drift detected: {result['drift_detected']}")
    print(f"  Drift share: {result['drift_share']:.1%}")
    print(f"  Drifted columns: {result['drifted_columns']}")
    print(f"  Alert triggered: {result['alert']}")
    
    # Scenario 2: Fraud spike (10% fraud instead of 2%)
    print("\n" + "-"*70)
    print("SCENARIO 2: Fraud spike (10% fraud rate instead of 2%)")
    print("-"*70)
    fraud_spike = generate_transactions(n_samples=2000, fraud_ratio=0.10, seed=101)
    result = monitor.check_drift(fraud_spike)
    print(f"  Drift detected: {result['drift_detected']}")
    print(f"  Drift share: {result['drift_share']:.1%}")
    print(f"  Drifted columns: {result['drifted_columns']}")
    print(f"  Alert triggered: {result['alert']}")
    
    # Scenario 3: Amount inflation (everything costs more)
    print("\n" + "-"*70)
    print("SCENARIO 3: Amount inflation (2x multiplier)")
    print("-"*70)
    inflated = test_data.copy()
    inflated['amount'] = inflated['amount'] * 2
    result = monitor.check_drift(inflated)
    print(f"  Drift detected: {result['drift_detected']}")
    print(f"  Drift share: {result['drift_share']:.1%}")
    print(f"  Drifted columns: {result['drifted_columns']}")
    print(f"  Alert triggered: {result['alert']}")
    
    # Scenario 4: Time shift (more late-night transactions)
    print("\n" + "-"*70)
    print("SCENARIO 4: Time shift (mostly late-night transactions)")
    print("-"*70)
    night_shift = test_data.copy()
    night_shift['hour'] = np.random.choice([0, 1, 2, 3, 22, 23], size=len(night_shift))
    result = monitor.check_drift(night_shift)
    print(f"  Drift detected: {result['drift_detected']}")
    print(f"  Drift share: {result['drift_share']:.1%}")
    print(f"  Drifted columns: {result['drifted_columns']}")
    print(f"  Alert triggered: {result['alert']}")
    
    # Generate detailed report for the most drifted scenario
    print("\n" + "-"*70)
    print("GENERATING DETAILED REPORT")
    print("-"*70)
    monitor.generate_report(night_shift, "drift_report.html")
    
    # Print summary
    print("\n" + "-"*70)
    print("MONITORING SUMMARY")
    print("-"*70)
    summary = monitor.summary()
    print(f"  Total checks: {summary['total_checks']}")
    print(f"  Total alerts: {summary['total_alerts']}")
    print(f"  Average drift share: {summary['avg_drift_share']:.1%}")
    print(f"  Maximum drift share: {summary['max_drift_share']:.1%}")
    
    # Print alerts
    alerts = monitor.get_alerts()
    if alerts:
        print(f"\n  Alerts ({len(alerts)}):")
        for alert in alerts:
            print(f"    [{alert['severity']}] {alert['message']}")
    
    print("\n" + "="*70)
    print("DRIFT DETECTION SIMULATION COMPLETE")
    print("="*70)
    print("\nOpen drift_report.html in your browser to see detailed visualizations!")


if __name__ == "__main__":
    simulate_drift_scenarios()
</code></pre>
<p>Run the drift simulation:</p>
<pre><code class="language-python">python src/monitoring.py
</code></pre>
<p>You'll see output showing how drift detection works in different scenarios. Then open <code>drift_report.html</code> in your browser to see beautiful visualizations of the drift patterns.</p>
<h3 id="heading-63-production-monitoring-strategy"><strong>6.3 Production Monitoring Strategy</strong></h3>
<p>In a production environment, you would:</p>
<ol>
<li><p><strong>Log all predictions</strong> to a database or data warehouse</p>
</li>
<li><p><strong>Run drift checks periodically</strong> (hourly for high-traffic systems, daily for lower traffic)</p>
</li>
<li><p><strong>Set up alerts</strong> when drift exceeds thresholds (integrate with PagerDuty, Slack, etc.)</p>
</li>
<li><p><strong>Trigger retraining</strong> if drift is severe or sustained</p>
</li>
<li><p><strong>Create dashboards</strong> to track drift over time (Grafana, Datadog, etc.)</p>
</li>
</ol>
<p><strong>Checkpoint:</strong> Running <code>python src/</code><a href="http://monitoring.py"><code>monitoring.py</code></a> should:</p>
<ul>
<li><p>Show minimal drift for similar data (test set)</p>
</li>
<li><p>Show significant drift for modified data (fraud spike, inflation, time shift)</p>
</li>
<li><p>Generate an HTML report that you can view in your browser</p>
</li>
</ul>
<h2 id="heading-7-automate-testing-and-deployment-with-cicd"><strong>7. Automate Testing and Deployment with CI/CD</strong></h2>
<p><strong>What breaks without this:</strong> A typo in your code breaks the API. You deploy on Friday at 5 PM. Nobody notices until Monday. Fraud losses spike over the weekend.</p>
<p><strong>CI/CD</strong> (Continuous Integration/Continuous Deployment) ensures reliable, repeatable releases. As JFrog notes: <em>"A strong CI/CD pipeline enables ML teams to build robust, bug-free models more quickly and efficiently."</em></p>
<p><strong>Why This Matters:</strong> In ML, changes aren't just code – they're also data and models. CI/CD ensures that when you change training logic, data preprocessing, or hyperparameters, tests verify the change doesn't break anything before it reaches production. It's the difference between deploying with confidence and deploying with crossed fingers.</p>
<h3 id="heading-71-write-tests-for-data-and-model"><strong>7.1 Write Tests for Data and Model</strong></h3>
<p>Create <code>tests/test_data_and_</code><a href="http://model.py"><code>model.py</code></a>:</p>
<pre><code class="language-python"># tests/test_data_and_model.py
"""
Tests for data quality and model performance.

These tests run in CI/CD to ensure:
1. Data meets quality requirements
2. Model meets performance thresholds
3. No regressions are introduced

Run with: pytest tests/test_data_and_model.py -v
"""
import pandas as pd
import pickle
import pytest
from sklearn.metrics import accuracy_score, f1_score, precision_score, recall_score

class TestDataQuality:
    """Tests for training data quality."""
    
    @pytest.fixture
    def train_data(self):
        return pd.read_csv("data/train.csv")
    
    @pytest.fixture
    def test_data(self):
        return pd.read_csv("data/test.csv")
    
    def test_train_data_has_expected_columns(self, train_data):
        """Training data must have all required columns."""
        required_columns = {"amount", "hour", "day_of_week", "merchant_category", "is_fraud"}
        actual_columns = set(train_data.columns)
        missing = required_columns - actual_columns
        assert not missing, f"Missing columns: {missing}"
    
    def test_train_data_not_empty(self, train_data):
        """Training data must have rows."""
        assert len(train_data) &gt; 0, "Training data is empty"
        assert len(train_data) &gt;= 1000, f"Training data too small: {len(train_data)} rows"
    
    def test_no_negative_amounts(self, train_data):
        """Transaction amounts must be non-negative."""
        negative_count = (train_data["amount"] &lt; 0).sum()
        assert negative_count == 0, f"Found {negative_count} negative amounts"
    
    def test_amounts_reasonable(self, train_data):
        """Transaction amounts should be within reasonable bounds."""
        max_amount = train_data["amount"].max()
        assert max_amount &lt;= 100000, f"Max amount {max_amount} exceeds reasonable limit"
    
    def test_hours_valid(self, train_data):
        """Hours must be 0-23."""
        invalid = train_data[(train_data["hour"] &lt; 0) | (train_data["hour"] &gt; 23)]
        assert len(invalid) == 0, f"Found {len(invalid)} invalid hours"
    
    def test_days_valid(self, train_data):
        """Days of week must be 0-6."""
        invalid = train_data[(train_data["day_of_week"] &lt; 0) | (train_data["day_of_week"] &gt; 6)]
        assert len(invalid) == 0, f"Found {len(invalid)} invalid days"
    
    def test_merchant_categories_valid(self, train_data):
        """Merchant categories must be from known set."""
        valid_categories = {"grocery", "restaurant", "retail", "online", "travel"}
        actual_categories = set(train_data["merchant_category"].unique())
        invalid = actual_categories - valid_categories
        assert not invalid, f"Invalid merchant categories: {invalid}"
    
    def test_fraud_ratio_reasonable(self, train_data):
        """Fraud ratio should be realistic (between 0.1% and 50%)."""
        fraud_ratio = train_data["is_fraud"].mean()
        assert 0.001 &lt;= fraud_ratio &lt;= 0.5, f"Fraud ratio {fraud_ratio:.2%} is unrealistic"
    
    def test_no_nulls_in_critical_columns(self, train_data):
        """Critical columns must not have null values."""
        critical = ["amount", "hour", "day_of_week", "merchant_category", "is_fraud"]
        for col in critical:
            null_count = train_data[col].isnull().sum()
            assert null_count == 0, f"Column {col} has {null_count} null values"


class TestModelPerformance:
    """Tests for model performance thresholds."""
    
    @pytest.fixture
    def model_and_encoder(self):
        with open("models/model.pkl", "rb") as f:
            return pickle.load(f)
    
    @pytest.fixture
    def test_data(self):
        return pd.read_csv("data/test.csv")
    
    def test_model_loads_successfully(self, model_and_encoder):
        """Model file must load without errors."""
        model, encoder = model_and_encoder
        assert model is not None, "Model is None"
        assert encoder is not None, "Encoder is None"
    
    def test_model_can_predict(self, model_and_encoder, test_data):
        """Model must be able to make predictions."""
        model, encoder = model_and_encoder
        test_data["merchant_encoded"] = encoder.transform(test_data["merchant_category"])
        X = test_data[["amount", "hour", "day_of_week", "merchant_encoded"]]
        predictions = model.predict(X)
        assert len(predictions) == len(X), "Prediction count mismatch"
    
    def test_accuracy_threshold(self, model_and_encoder, test_data):
        """Model accuracy must be at least 90%."""
        model, encoder = model_and_encoder
        test_data["merchant_encoded"] = encoder.transform(test_data["merchant_category"])
        X = test_data[["amount", "hour", "day_of_week", "merchant_encoded"]]
        y = test_data["is_fraud"]
        accuracy = model.score(X, y)
        assert accuracy &gt;= 0.90, f"Accuracy {accuracy:.2%} below 90% threshold"
    
    def test_f1_threshold(self, model_and_encoder, test_data):
        """Model F1-score must be at least 0.3 (sanity check for imbalanced data)."""
        model, encoder = model_and_encoder
        test_data["merchant_encoded"] = encoder.transform(test_data["merchant_category"])
        X = test_data[["amount", "hour", "day_of_week", "merchant_encoded"]]
        y = test_data["is_fraud"]
        y_pred = model.predict(X)
        f1 = f1_score(y, y_pred)
        assert f1 &gt;= 0.3, f"F1-score {f1:.2f} below 0.3 threshold"
    
    def test_precision_not_zero(self, model_and_encoder, test_data):
        """Model precision must be greater than 0 (catches at least some fraud)."""
        model, encoder = model_and_encoder
        test_data["merchant_encoded"] = encoder.transform(test_data["merchant_category"])
        X = test_data[["amount", "hour", "day_of_week", "merchant_encoded"]]
        y = test_data["is_fraud"]
        y_pred = model.predict(X)
        precision = precision_score(y, y_pred, zero_division=0)
        assert precision &gt; 0, "Model has zero precision (predicts no fraud)"
    
    def test_recall_not_zero(self, model_and_encoder, test_data):
        """Model recall must be greater than 0 (catches at least some fraud)."""
        model, encoder = model_and_encoder
        test_data["merchant_encoded"] = encoder.transform(test_data["merchant_category"])
        X = test_data[["amount", "hour", "day_of_week", "merchant_encoded"]]
        y = test_data["is_fraud"]
        y_pred = model.predict(X)
        recall = recall_score(y, y_pred, zero_division=0)
        assert recall &gt; 0, "Model has zero recall (misses all fraud)"
</code></pre>
<p>Create <code>tests/test_</code><a href="http://api.py"><code>api.py</code></a>:</p>
<pre><code class="language-python"># tests/test_api.py
"""
Tests for the FastAPI prediction service.

These tests ensure the API:
1. Returns correct responses for valid inputs
2. Rejects invalid inputs with proper error messages
3. Health check works

Run with: pytest tests/test_api.py -v
Note: Requires the API to be running on localhost:8000
"""
import pytest
import httpx

BASE_URL = "http://localhost:8000"

class TestPredictionEndpoint:
    """Tests for the /predict endpoint."""
    
    def test_valid_prediction_returns_200(self):
        """Valid input should return HTTP 200 with prediction."""
        response = httpx.post(f"{BASE_URL}/predict", json={
            "amount": 100.0,
            "hour": 14,
            "day_of_week": 3,
            "merchant_category": "online"
        }, timeout=10)
        
        assert response.status_code == 200
        data = response.json()
        assert "is_fraud" in data
        assert "fraud_probability" in data
        assert isinstance(data["is_fraud"], bool)
        assert 0 &lt;= data["fraud_probability"] &lt;= 1
    
    def test_high_risk_transaction(self):
        """High-risk transaction should have higher fraud probability."""
        response = httpx.post(f"{BASE_URL}/predict", json={
            "amount": 500.0,
            "hour": 3,  # Late night
            "day_of_week": 1,
            "merchant_category": "online"
        }, timeout=10)
        
        assert response.status_code == 200
        data = response.json()
        # High-risk transactions should have elevated probability
        # (not asserting exact value as model may vary)
        assert data["fraud_probability"] &gt;= 0.0
    
    def test_negative_amount_rejected(self):
        """Negative amount should be rejected with 400."""
        response = httpx.post(f"{BASE_URL}/predict", json={
            "amount": -100.0,
            "hour": 14,
            "day_of_week": 3,
            "merchant_category": "online"
        }, timeout=10)
        
        assert response.status_code == 400
        assert "errors" in response.json()["detail"]
    
    def test_invalid_hour_rejected(self):
        """Invalid hour should be rejected with 400."""
        response = httpx.post(f"{BASE_URL}/predict", json={
            "amount": 100.0,
            "hour": 25,  # Invalid
            "day_of_week": 3,
            "merchant_category": "online"
        }, timeout=10)
        
        assert response.status_code == 400
    
    def test_invalid_merchant_rejected(self):
        """Unknown merchant category should be rejected with 400."""
        response = httpx.post(f"{BASE_URL}/predict", json={
            "amount": 100.0,
            "hour": 14,
            "day_of_week": 3,
            "merchant_category": "unknown_category"
        }, timeout=10)
        
        assert response.status_code == 400
    
    def test_missing_field_rejected(self):
        """Missing required field should be rejected."""
        response = httpx.post(f"{BASE_URL}/predict", json={
            "amount": 100.0,
            "hour": 14
            # Missing day_of_week and merchant_category
        }, timeout=10)
        
        assert response.status_code == 422  # Pydantic validation error


class TestHealthEndpoint:
    """Tests for the /health endpoint."""
    
    def test_health_returns_200(self):
        """Health endpoint should return 200."""
        response = httpx.get(f"{BASE_URL}/health", timeout=10)
        assert response.status_code == 200
    
    def test_health_returns_healthy_status(self):
        """Health endpoint should indicate healthy status."""
        response = httpx.get(f"{BASE_URL}/health", timeout=10)
        data = response.json()
        assert data["status"] == "healthy"
</code></pre>
<p>Run tests locally:</p>
<pre><code class="language-python"># Run data and model tests (API not needed)
pytest tests/test_data_and_model.py -v

# Run API tests (requires API to be running)
pytest tests/test_api.py -v
</code></pre>
<h3 id="heading-72-github-actions-workflow"><strong>7.2 GitHub Actions Workflow</strong></h3>
<p>⚠️ <strong>Note for Production Teams</strong><br>In real ML teams, you typically don't retrain full models inside CI — it's slow and resource-intensive.<br>Here we do it to keep everything local, reproducible, and self-contained for learning.<br>Production pipelines usually separate training (scheduled jobs) from testing (CI/CD).</p>
<p>Create <code>.github/workflows/ci.yml</code>:</p>
<pre><code class="language-python"># .github/workflows/ci.yml
name: ML Pipeline CI/CD

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
      
      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"
          cache: 'pip'
      
      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt
      
      - name: Generate training data
        run: python src/generate_data.py
      
      - name: Train model
        run: python src/train_naive.py
      
      - name: Run data quality tests
        run: pytest tests/test_data_and_model.py -v --tb=short
      
      - name: Build Docker image
        run: docker build -t fraud-detection-api .
      
      - name: Run container for API tests
        run: |
          docker run -d -p 8000:8000 --name test-api fraud-detection-api
          sleep 10  # Wait for API to start
          curl -f http://localhost:8000/health || exit 1
      
      - name: Run API tests
        run: pytest tests/test_api.py -v --tb=short
      
      - name: Cleanup
        if: always()
        run: docker stop test-api || true
</code></pre>
<h3 id="heading-73-dockerize-the-application"><strong>7.3 Dockerize the Application</strong></h3>
<p>Create <code>Dockerfile</code>:</p>
<pre><code class="language-python"># Dockerfile
FROM python:3.11-slim

# Set working directory
WORKDIR /app

# Install system dependencies
RUN apt-get update &amp;&amp; apt-get install -y \
    curl \
    &amp;&amp; rm -rf /var/lib/apt/lists/*

# Copy and install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy application code
COPY src/ src/
COPY models/ models/
COPY data/ data/

# Expose port
EXPOSE 8000

# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
    CMD curl -f http://localhost:8000/health || exit 1

# Run the API
CMD ["uvicorn", "src.serve_validated:app", "--host", "0.0.0.0", "--port", "8000"]
</code></pre>
<p>Create <code>.dockerignore</code>:</p>
<pre><code class="language-python"># .dockerignore
venv/
__pycache__/
*.pyc
.git/
.github/
mlruns/
*.db
*.html
.pytest_cache/
</code></pre>
<p>Build and run locally:</p>
<pre><code class="language-python"># Build the Docker image
docker build -t fraud-detection-api .

# Run the container
docker run -p 8000:8000 fraud-detection-api

# Test it
curl http://localhost:8000/health
</code></pre>
<p><strong>Checkpoint:</strong></p>
<ul>
<li><p>All tests pass: <code>pytest tests/test_data_and_</code><a href="http://model.py"><code>model.py</code></a> <code>-v</code></p>
</li>
<li><p>Docker image builds successfully</p>
</li>
<li><p>Container runs and responds to health checks</p>
</li>
</ul>
<h2 id="heading-8-incident-response-playbook"><strong>8. Incident Response Playbook</strong></h2>
<p>When things go wrong in production (and they will), you need a plan. This section provides playbooks for common ML incidents.</p>
<h3 id="heading-scenario-false-positive-spike"><strong>Scenario: False Positive Spike</strong></h3>
<p><strong>Symptoms:</strong> Your fraud model suddenly flags 40% of legitimate transactions as fraud, blocking customers and overwhelming your manual review team.</p>
<p><strong>Severity:</strong> HIGH - Direct customer impact</p>
<p><strong>Phase 1: Mitigation (0-5 minutes)</strong></p>
<ol>
<li><p><strong>Acknowledge the incident</strong> - Notify stakeholders that you're aware and responding</p>
</li>
<li><p><strong>Roll back to previous model</strong> - In MLflow UI, move the @champion alias to the previous model version</p>
</li>
<li><p><strong>Restart the API</strong> - <code>docker restart fraud-api</code> or redeploy</p>
</li>
<li><p><strong>Verify</strong> - Check that false positive rate has returned to normal</p>
</li>
<li><p><strong>Communicate</strong> - "Issue detected and mitigated. Investigating root cause."</p>
</li>
</ol>
<p><strong>Phase 2: Diagnosis (5-60 minutes)</strong></p>
<ol>
<li><p><strong>Check drift report</strong> - Run <code>python src/</code><a href="http://monitoring.py"><code>monitoring.py</code></a> with recent production data</p>
</li>
<li><p><strong>Check data validation logs</strong> - Did upstream data format change?</p>
</li>
<li><p><strong>Check recent deployments</strong> - Was there a new model or code deployed recently?</p>
</li>
<li><p><strong>Compare metrics</strong> - What's different between the rolled-back and problematic model?</p>
</li>
</ol>
<p><strong>Example root causes:</strong></p>
<ul>
<li><p>Upstream system sent amounts in cents instead of dollars</p>
</li>
<li><p>New merchant category appeared that wasn't in training data</p>
</li>
<li><p>Holiday shopping patterns differed significantly from training data</p>
</li>
</ul>
<p><strong>Phase 3: Remediation (1-24 hours)</strong></p>
<ol>
<li><p><strong>Fix the root cause</strong> - Add validation for the edge case, or update training data</p>
</li>
<li><p><strong>Retrain if needed</strong> - Include new patterns in training data</p>
</li>
<li><p><strong>Add test case</strong> - Prevent this from happening again</p>
</li>
<li><p><strong>Document</strong> - Add to runbook for future reference</p>
</li>
</ol>
<h3 id="heading-scenario-gradual-performance-decay"><strong>Scenario: Gradual Performance Decay</strong></h3>
<p><strong>Symptoms:</strong> Monitoring shows fraud recall dropping 2% per week over a month. No sudden failures, just slow degradation.</p>
<p><strong>Severity:</strong> MEDIUM - Gradual impact, time to respond</p>
<p><strong>Response:</strong></p>
<ol>
<li><p><strong>Investigate drift report</strong> - Look for gradual distribution changes</p>
<pre><code class="language-python">python src/monitoring.py
</code></pre>
</li>
<li><p><strong>Collect recent labeled data</strong> - Get confirmed fraud cases from the past month</p>
</li>
<li><p><strong>Analyze patterns</strong> - What's different about recent fraud?</p>
<ul>
<li><p>New attack vectors?</p>
</li>
<li><p>Different time patterns?</p>
</li>
<li><p>New merchant categories?</p>
</li>
</ul>
</li>
<li><p><strong>Retrain on combined data</strong> - Include both old and new patterns</p>
<pre><code class="language-python">python src/train_mlflow.py
</code></pre>
</li>
<li><p><strong>Deploy via canary</strong> - Route 10% of traffic to the new model first</p>
<ul>
<li><p>Monitor metrics for 1-2 days</p>
</li>
<li><p>If metrics improve, increase to 50%, then 100%</p>
</li>
<li><p>If metrics worsen, roll back</p>
</li>
</ul>
</li>
<li><p><strong>Set up recurring retraining</strong> - Schedule weekly or monthly retraining</p>
</li>
</ol>
<h3 id="heading-scenario-upstream-data-schema-change"><strong>Scenario: Upstream Data Schema Change</strong></h3>
<p><strong>Symptoms:</strong> API starts returning 500 errors. Logs show <code>KeyError: 'merchant_category'</code>.</p>
<p><strong>Severity:</strong> HIGH - Service is down</p>
<p><strong>Response:</strong></p>
<ol>
<li><p><strong>Check error logs</strong> - Identify the exact error</p>
<pre><code class="language-python">KeyError: 'merchant_category'
</code></pre>
</li>
<li><p><strong>Check upstream data</strong> - Did the field name change?</p>
<ul>
<li><p><code>merchant_category</code> -&gt; <code>category</code></p>
</li>
<li><p><code>amount</code> -&gt; <code>transaction_amount</code></p>
</li>
</ul>
</li>
<li><p><strong>Immediate fix</strong> - Add field name mapping</p>
<pre><code class="language-python"># Quick fix in API
if 'category' in data and 'merchant_category' not in data:
    data['merchant_category'] = data['category']
</code></pre>
</li>
<li><p><strong>Long-term fix</strong> - Add validation that catches schema changes</p>
<pre><code class="language-python">required_fields = ['amount', 'hour', 'day_of_week', 'merchant_category']
missing = [f for f in required_fields if f not in data]
if missing:
    raise ValidationError(f"Missing fields: {missing}")
</code></pre>
</li>
<li><p><strong>Add integration test</strong> - Test with upstream system in CI/CD</p>
</li>
</ol>
<h2 id="heading-9-how-to-put-it-all-together"><strong>9.</strong> How to Put It All Together</h2>
<p>Let's step back and appreciate what we've built. Our initial naive system has transformed into a <strong>local ML platform</strong> with production-grade components.</p>
<blockquote>
<p>💡 <strong>Mental Model</strong>: Each tool in this stack is a "catch net" for a specific failure mode:</p>
<ul>
<li><p>MLflow catches "which model is this?"</p>
</li>
<li><p>Feast catches "are features consistent?"</p>
</li>
<li><p>Great Expectations catches "is this data valid?"</p>
</li>
<li><p>Evidently catches "has the world changed?"</p>
</li>
<li><p>CI/CD catches "did we break something?"</p>
</li>
</ul>
<p>Together, they form defense-in-depth for ML systems.</p>
</blockquote>
<table>
<thead>
<tr>
<th><strong>Component</strong></th>
<th><strong>Tool</strong></th>
<th><strong>Problem Solved</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>Experiment Tracking</strong></td>
<td>MLflow</td>
<td>Every run logged, reproducible</td>
</tr>
<tr>
<td><strong>Model Registry</strong></td>
<td>MLflow</td>
<td>Versioned models, rollback capability</td>
</tr>
<tr>
<td><strong>Feature Store</strong></td>
<td>Feast</td>
<td>Consistent features, no training-serving skew</td>
</tr>
<tr>
<td><strong>Data Validation</strong></td>
<td>Great Expectations</td>
<td>Bad data rejected with clear errors</td>
</tr>
<tr>
<td><strong>Monitoring</strong></td>
<td>Evidently</td>
<td>Drift detected before it causes problems</td>
</tr>
<tr>
<td><strong>Containerization</strong></td>
<td>Docker</td>
<td>Environment consistency everywhere</td>
</tr>
<tr>
<td><strong>CI/CD</strong></td>
<td>GitHub Actions</td>
<td>Automated testing and safe deployments</td>
</tr>
</tbody></table>
<h3 id="heading-the-complete-workflow"><strong>The Complete Workflow</strong></h3>
<p>Here's how all the pieces work together in practice:</p>
<ol>
<li><p><strong>Data arrives</strong> - New transaction data comes in from upstream systems</p>
</li>
<li><p><strong>Validation gate</strong> - Great Expectations rules check data quality. Bad data is rejected with clear error messages before it can cause harm.</p>
</li>
<li><p><strong>Feature computation</strong> - Feast computes features using the same definitions for both training and serving. No more training-serving skew.</p>
</li>
<li><p><strong>Training</strong> - When you retrain, MLflow logs all parameters, metrics, and artifacts. Every experiment is reproducible and comparable.</p>
</li>
<li><p><strong>Model registry</strong> - Trained models are automatically versioned. You can compare metrics, promote the best to Production, and roll back if needed.</p>
</li>
<li><p><strong>Serving</strong> - FastAPI loads the @champion model from MLflow. Each request is validated, features are retrieved from Feast, and predictions are returned.</p>
</li>
<li><p><strong>Monitoring</strong> - Evidently checks for drift periodically. If input distributions change significantly, alerts are triggered.</p>
</li>
<li><p><strong>Retraining loop</strong> - When drift is detected, you retrain on new data, compare metrics, and promote if better. The cycle continues.</p>
</li>
<li><p><strong>CI/CD safety net</strong> - All code changes go through automated tests. Docker ensures environment consistency. Nothing reaches production without passing the pipeline.</p>
</li>
</ol>
<h2 id="heading-10-whats-next-scale-to-production"><strong>10. What's Next: Scale to Production</strong></h2>
<p>This project runs locally, but the principles and tools extend directly to production deployments. Here's how each component scales:</p>
<h3 id="heading-scaling-feast-for-production"><strong>Scaling Feast for Production</strong></h3>
<p>We used Feast with local SQLite stores. For production:</p>
<table>
<thead>
<tr>
<th><strong>Component</strong></th>
<th><strong>Local</strong></th>
<th><strong>Production</strong></th>
</tr>
</thead>
<tbody><tr>
<td>Online Store</td>
<td>SQLite</td>
<td>Redis, DynamoDB, or PostgreSQL</td>
</tr>
<tr>
<td>Offline Store</td>
<td>Parquet files</td>
<td>BigQuery, Snowflake, or Redshift</td>
</tr>
<tr>
<td>Feature Server</td>
<td>Embedded</td>
<td>Dedicated Feast serving cluster</td>
</tr>
</tbody></table>
<p>Benefits at scale:</p>
<ul>
<li><p>Sub-10ms feature retrieval</p>
</li>
<li><p>Horizontal scaling for high throughput</p>
</li>
<li><p>Feature monitoring and statistics</p>
</li>
<li><p>Point-in-time joins at petabyte scale</p>
</li>
</ul>
<h3 id="heading-scaling-mlflow-for-production"><strong>Scaling MLflow for Production</strong></h3>
<table>
<thead>
<tr>
<th><strong>Component</strong></th>
<th><strong>Local</strong></th>
<th><strong>Production</strong></th>
</tr>
</thead>
<tbody><tr>
<td>Backend Store</td>
<td>SQLite</td>
<td>PostgreSQL or MySQL</td>
</tr>
<tr>
<td>Artifact Store</td>
<td>Local filesystem</td>
<td>S3, GCS, or Azure Blob</td>
</tr>
<tr>
<td>Tracking Server</td>
<td>Single instance</td>
<td>Load-balanced cluster</td>
</tr>
</tbody></table>
<h3 id="heading-kubernetes-deployment"><strong>Kubernetes Deployment</strong></h3>
<p>When you outgrow Docker Compose:</p>
<ul>
<li><p><strong>KServe or Seldon</strong> for serverless model serving with auto-scaling</p>
</li>
<li><p><strong>Horizontal Pod Autoscaler</strong> to scale based on CPU/memory/custom metrics</p>
</li>
<li><p><strong>Canary deployments</strong> to safely roll out new models (route 10% traffic first)</p>
</li>
<li><p><strong>GPU scheduling</strong> for inference-heavy models</p>
</li>
</ul>
<h3 id="heading-advanced-monitoring"><strong>Advanced Monitoring</strong></h3>
<p>Expand observability with:</p>
<ul>
<li><p><strong>Prometheus + Grafana</strong> for real-time dashboards</p>
</li>
<li><p><strong>OpenTelemetry</strong> for distributed tracing</p>
</li>
<li><p><strong>PagerDuty/Slack integration</strong> for alerts</p>
</li>
<li><p><strong>Labeled data collection</strong> for continuous model evaluation</p>
</li>
</ul>
<h3 id="heading-ab-testing-and-multi-armed-bandits"><strong>A/B Testing and Multi-Armed Bandits</strong></h3>
<p>How to Use the Model Registry:</p>
<ul>
<li><p>Serve <strong>multiple models</strong> concurrently (champion vs challengers)</p>
</li>
<li><p><strong>Route traffic</strong> dynamically based on context</p>
</li>
<li><p><strong>Collect metrics</strong> for each model variant</p>
</li>
<li><p><strong>Automatically promote</strong> the best performer</p>
</li>
</ul>
<h2 id="heading-conclusion"><strong>Conclusion</strong></h2>
<p>Congratulations on building a production-ready ML system on your local machine!</p>
<p>What we assembled here is a microcosm of real-world ML platforms:</p>
<ul>
<li><p>We started with just a model saved to a pickle file</p>
</li>
<li><p>We ended up with <strong>MLOps best practices</strong>: experiment tracking, model versioning, feature stores, data validation, monitoring, containerization, and CI/CD</p>
</li>
</ul>
<p><strong>The tools we used are production-grade:</strong></p>
<ul>
<li><p><strong>MLflow</strong> powers ML platforms at companies like Microsoft, Facebook, and Databricks</p>
</li>
<li><p><strong>Feast</strong> is used by companies like Gojek, Shopify, and Robinhood</p>
</li>
<li><p><strong>FastAPI</strong> is one of the fastest Python web frameworks</p>
</li>
<li><p><strong>Great Expectations</strong> is used at companies like GitHub and Shopify</p>
</li>
<li><p><strong>Evidently</strong> is used for monitoring ML in production at scale</p>
</li>
</ul>
<p><strong>The principles apply at any scale:</strong></p>
<ul>
<li><p>Always track experiments</p>
</li>
<li><p>Always version models</p>
</li>
<li><p>Always validate data</p>
</li>
<li><p>Always monitor for drift</p>
</li>
<li><p>Always containerize for consistency</p>
</li>
<li><p>Always automate testing</p>
</li>
</ul>
<h3 id="heading-next-steps-you-can-try"><strong>Next Steps You Can Try</strong></h3>
<ol>
<li><p><strong>Deploy to the cloud</strong> - Push your Docker container to AWS ECS, Google Cloud Run, or Azure Container Instances</p>
</li>
<li><p><strong>Add model explainability</strong> - Use SHAP or LIME to explain individual predictions</p>
</li>
<li><p><strong>Implement A/B testing</strong> - Serve multiple models and compare performance</p>
</li>
<li><p><strong>Add feature importance monitoring</strong> - Track how feature importance changes over time</p>
</li>
<li><p><strong>Set up real-time alerting</strong> - Connect Evidently to Slack or PagerDuty</p>
</li>
<li><p><strong>Implement continuous training</strong> - Automatically retrain when drift is detected</p>
</li>
<li><p><strong>Add bias and fairness monitoring</strong> - Ensure your model treats all groups fairly</p>
</li>
</ol>
<p>Remember that productionizing ML is an <strong>iterative process</strong>. There's always another layer of robustness to add, another edge case to handle, another metric to track. But with the foundation you've built here, you're well on your way to taking models from promising notebook experiments to deployed, monitored, and maintainable production applications.</p>
<p>Happy building, and may your models be accurate and your pipelines resilient!</p>
<h2 id="heading-get-the-complete-code">Get the Complete Code</h2>
<p>The entire project from this handbook is available as a public GitHub repository:</p>
<p><strong>🔗</strong> <a href="http://github.com/sandeepmb/freecodecamp-local-ml-platform"><strong>github.com/sandeepmb/freecodecamp-local-ml-platform</strong></a></p>
<p>The repository includes:</p>
<ul>
<li><p>All source code (<code>src/</code> directory)</p>
</li>
<li><p>Test files (<code>tests/</code> directory)</p>
</li>
<li><p>Feast feature definitions (<code>feature_repo/</code>)</p>
</li>
<li><p>Docker and CI/CD configuration</p>
</li>
<li><p>Ready-to-run scripts</p>
</li>
</ul>
<p><strong>Quick Start:</strong></p>
<pre><code class="language-bash">git clone https://github.com/sandeepmb/freecodecamp-local-ml-platform.git
cd freecodecamp-local-ml-platform
python -m venv venv &amp;&amp; source venv/bin/activate
pip install -r requirements.txt
python src/generate_data.py
python src/train_naive.py
</code></pre>
<hr>
<h2 id="heading-references"><strong>References</strong></h2>
<ul>
<li><p><a href="https://mlflow.org/docs/latest/">MLflow Documentation</a> - Experiment tracking and model registry</p>
</li>
<li><p><a href="https://docs.feast.dev/">Feast Documentation</a> - Feature store</p>
</li>
<li><p><a href="https://docs.feast.dev/getting-started/quickstart">Feast Quickstart</a> - Getting started with Feast</p>
</li>
<li><p><a href="https://fastapi.tiangolo.com/">FastAPI Documentation</a> - Modern Python web framework</p>
</li>
<li><p><a href="https://greatexpectations.io/">Great Expectations</a> - Data validation</p>
</li>
<li><p><a href="https://docs.evidentlyai.com/">Evidently AI Documentation</a> - ML monitoring</p>
</li>
<li><p><a href="https://jfrog.com/learn/mlops/cicd-for-machine-learning/">CI/CD for Machine Learning (JFrog)</a> - CI/CD best practices</p>
</li>
<li><p><a href="https://www.qwak.com/post/training-serving-skew-in-machine-learning">Training-Serving Skew Explained</a> - Understanding skew</p>
</li>
<li><p><a href="https://docs.docker.com/">Docker Documentation</a> - Containerization</p>
</li>
<li><p><a href="https://docs.github.com/en/actions">GitHub Actions Documentation</a> - CI/CD automation</p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
