Skip to main content

09 - ML Governance: Compliance, Explainability and Ethics

Complete guide to ML Governance: Compliance, Explainability and Ethics: architecture, practical implementation and best practices for developers and technical teams.

What do you think about this article?

At a Glance

Complete guide to ML Governance: Compliance, Explainability and Ethics: architecture, practical implementation and best practices for developers and technical teams.

ML Governance and Compliance: Responsible AI in Production

Your churn prediction model hits 92% accuracy, the FastAPI serving layer is stable, and the Kubernetes cluster scales automatically. Then an email arrives from the legal team: "Our credit scoring model falls under the High-Risk category of the EU AI Act. We need to prove it does not discriminate based on gender or age. Where are the decision logs from the last 6 months? Who approved the production deployment?"

If you cannot answer these questions immediately, your ML project is at risk. The EU Artificial Intelligence Regulation (AI Act), which entered partial force in August 2025 with the final compliance deadline for high-risk systems set for 2 August 2026, has transformed ML governance from an optional best practice into a legal obligation with penalties of up to 6% of global annual turnover. The MLOps market, valued at $4.38 billion in 2026 and growing at 39.8% CAGR, is partly driven by enterprises needing governance infrastructure to remain compliant.

In this article, we build a complete open-source ML governance framework: from AI Act requirements analysis, to automated model card generation, MLflow audit trails, fairness detection with Fairlearn, and explainability with SHAP. All code is functional Python, applicable immediately, even on a limited budget.

What You Will Learn

  • Practical EU AI Act requirements for high-risk ML systems (August 2026 deadline)
  • Generating automated model cards with standardized documentation
  • Implementing complete audit trails with MLflow and structured logging
  • Measuring and mitigating bias with Fairlearn (demographic parity, equalized odds)
  • Explainability with SHAP: global feature importance and local explanations
  • Model registry governance: stage transitions with tracked approvals
  • Risk assessment framework to classify your AI models
  • Responsible AI checklist for SMEs with budgets under EUR 5,000/year

EU AI Act: What Changes for Your ML Models

The AI Act classifies AI systems into four risk levels with increasing obligations. Before implementing any governance framework, you need to understand where your model sits in this taxonomy. Classification depends not on the technology used (your XGBoost is not inherently more or less risky than a transformer) but on the intended use and application domain.

The 4 AI Act Risk Categories

Unacceptable Risk (prohibited): social scoring systems, subliminal manipulation, exploitation of vulnerabilities. No deployment possible.
High Risk (strict requirements): systems for credit, hiring, education, critical infrastructure, medical devices, migration. Compliance deadline: 2 August 2026.
Limited Risk (transparency required): chatbots, deepfakes, recommendation systems. Obligation to inform users they are interacting with AI.
Minimal Risk (voluntary): spam filters, AI games, standard recommendations. No specific regulatory obligations.

For high-risk systems, the AI Act imposes a set of technical and organizational obligations that the ML team must implement in their MLOps pipeline. The main requirements, effective from August 2026, cover:

  • Risk Management System: a continuous process to identify, analyze, and mitigate risks throughout the entire model lifecycle.
  • Data Governance: training, validation, and test datasets must be documented, representative, free from bias, and appropriate for the stated purpose.
  • Technical Documentation: technical documentation sufficient to demonstrate compliance to supervisory authorities. Includes architecture, training procedure, performance metrics, and known limitations.
  • Automatic Record-Keeping: the system must automatically log relevant events during operation, with immutable logs for audit purposes.
  • Transparency and User Information: users must know they are interacting with an AI system and receive comprehensible information about its capabilities and limits.
  • Human Oversight: technical mechanisms to enable human supervision, intervention, and override of automated decisions.
  • Accuracy, Robustness, Cybersecurity: documented performance metrics, robustness testing against distributional shift and adversarial inputs.

AI Act Penalties: They Are Real

Non-compliance penalties under the AI Act are substantial: up to EUR 30 million or 6% of global annual turnover for violations related to unacceptable-risk systems; up to EUR 20 million or 4% for other obligations; up to EUR 10 million or 2% for providing incorrect information to authorities. The first critical deadline for high-risk systems is 2 August 2026. If your model operates in the credit, hiring, or critical infrastructure domains, your compliance plan must start today.

Model Cards: Standardized Model Documentation

Model cards, introduced by Google in 2019 and now adopted as an industry standard, are structured documents that describe an ML model: purpose, performance across demographic subgroups, known limitations, intended and recommended use. The AI Act implicitly requires them under "technical documentation". Generating them manually is error-prone: the following code automates creation from training metadata and evaluation results.

# model_card_generator.py
# Automated Model Card generator compliant with the EU AI Act
# Compatible with MLflow for artifact tracking

import json
import datetime
from dataclasses import dataclass, field, asdict
from typing import Optional
import mlflow
import pandas as pd
import numpy as np
from sklearn.metrics import (
    accuracy_score, precision_score, recall_score,
    f1_score, roc_auc_score, confusion_matrix
)


@dataclass
class ModelCardMetrics:
    """Model performance metrics."""
    accuracy: float
    precision: float
    recall: float
    f1: float
    auc_roc: Optional[float] = None
    sample_size: int = 0
    evaluation_date: str = ""


@dataclass
class SubgroupMetrics:
    """Metrics per demographic subgroup (required by EU AI Act)."""
    group_name: str
    group_value: str
    accuracy: float
    precision: float
    recall: float
    false_positive_rate: float
    sample_size: int


@dataclass
class ModelCard:
    """Standardized Model Card compliant with EU AI Act guidelines."""
    # Model identification
    model_name: str
    model_version: str
    model_type: str
    creation_date: str
    last_updated: str

    # Description
    intended_use: str
    out_of_scope_uses: str
    primary_intended_users: str

    # Training data
    training_data_description: str
    training_data_size: int
    feature_names: list
    target_variable: str
    sensitive_features: list

    # Overall performance
    overall_metrics: ModelCardMetrics = field(default_factory=ModelCardMetrics)

    # Per-subgroup performance (fairness)
    subgroup_metrics: list = field(default_factory=list)

    # Known limitations
    limitations: list = field(default_factory=list)

    # Ethical considerations
    ethical_considerations: str = ""

    # Compliance
    risk_category: str = ""   # "high-risk", "limited-risk", "minimal-risk"
    regulatory_scope: list = field(default_factory=list)

    # Approvals and contacts
    model_owner: str = ""
    approved_by: str = ""
    approval_date: str = ""
    contact: str = ""

    def to_json(self) -> str:
        return json.dumps(asdict(self), indent=2, ensure_ascii=False)

    def to_markdown(self) -> str:
        """Generate Model Card in Markdown format for documentation."""
        md = f"""# Model Card: {self.model_name} v{self.model_version}

**Type:** {self.model_type}
**Creation date:** {self.creation_date}
**Last updated:** {self.last_updated}
**Owner:** {self.model_owner}
**Approved by:** {self.approved_by} ({self.approval_date})
**AI Act risk category:** {self.risk_category}

## Intended Use
{self.intended_use}

## Out-of-Scope Use
{self.out_of_scope_uses}

## Primary Users
{self.primary_intended_users}

## Training Data
- Description: {self.training_data_description}
- Size: {self.training_data_size:,} samples
- Features: {', '.join(self.feature_names)}
- Target: {self.target_variable}
- Sensitive features (for fairness): {', '.join(self.sensitive_features)}

## Overall Performance
| Metric | Value |
|--------|-------|
| Accuracy | {self.overall_metrics.accuracy:.4f} |
| Precision | {self.overall_metrics.precision:.4f} |
| Recall | {self.overall_metrics.recall:.4f} |
| F1 Score | {self.overall_metrics.f1:.4f} |
| AUC-ROC | {self.overall_metrics.auc_roc:.4f} |
| Evaluation samples | {self.overall_metrics.sample_size:,} |

## Per-Subgroup Performance (Fairness Analysis)
"""
        for sg in self.subgroup_metrics:
            md += f"""
### {sg['group_name']} = {sg['group_value']} (n={sg['sample_size']})
- Accuracy: {sg['accuracy']:.4f}
- Precision: {sg['precision']:.4f}
- Recall: {sg['recall']:.4f}
- False Positive Rate: {sg['false_positive_rate']:.4f}
"""
        md += "\n## Known Limitations\n"
        for lim in self.limitations:
            md += f"- {lim}\n"
        md += f"\n## Ethical Considerations\n{self.ethical_considerations}\n\n## Contact\n{self.contact}\n"
        return md


def generate_model_card(
    model_name: str,
    model_version: str,
    model,
    X_test: pd.DataFrame,
    y_test: pd.Series,
    sensitive_cols: list,
    intended_use: str,
    config: dict
) -> ModelCard:
    """
    Generate a complete Model Card from the trained model and test data.

    Args:
        model: Trained scikit-learn compatible model
        X_test: Test feature set
        y_test: Test labels
        sensitive_cols: Sensitive columns for fairness analysis
        intended_use: Description of intended use
        config: Additional config (owner, contact, risk_category, etc.)
    """
    y_pred = model.predict(X_test)
    y_proba = model.predict_proba(X_test)[:, 1] if hasattr(model, 'predict_proba') else None

    # Global metrics
    overall = ModelCardMetrics(
        accuracy=accuracy_score(y_test, y_pred),
        precision=precision_score(y_test, y_pred, zero_division=0),
        recall=recall_score(y_test, y_pred, zero_division=0),
        f1=f1_score(y_test, y_pred, zero_division=0),
        auc_roc=roc_auc_score(y_test, y_proba) if y_proba is not None else None,
        sample_size=len(y_test),
        evaluation_date=datetime.datetime.utcnow().isoformat()
    )

    # Per-subgroup metrics (REQUIRED by EU AI Act for High-Risk systems)
    subgroup_list = []
    for col in sensitive_cols:
        if col not in X_test.columns:
            continue
        for val in X_test[col].unique():
            mask = X_test[col] == val
            if mask.sum() < 30:
                continue
            y_sub = y_test[mask]
            y_sub_pred = y_pred[mask]
            cm = confusion_matrix(y_sub, y_sub_pred, labels=[0, 1])
            tn, fp, fn, tp = cm.ravel() if cm.size == 4 else (0, 0, 0, cm[0][0])
            fpr = fp / (fp + tn) if (fp + tn) > 0 else 0.0

            subgroup_list.append({
                "group_name": col,
                "group_value": str(val),
                "accuracy": accuracy_score(y_sub, y_sub_pred),
                "precision": precision_score(y_sub, y_sub_pred, zero_division=0),
                "recall": recall_score(y_sub, y_sub_pred, zero_division=0),
                "false_positive_rate": fpr,
                "sample_size": int(mask.sum())
            })

    return ModelCard(
        model_name=model_name,
        model_version=model_version,
        model_type=type(model).__name__,
        creation_date=config.get("creation_date", datetime.date.today().isoformat()),
        last_updated=datetime.date.today().isoformat(),
        intended_use=intended_use,
        out_of_scope_uses=config.get("out_of_scope_uses", "Not specified"),
        primary_intended_users=config.get("primary_users", "Data Science Team"),
        training_data_description=config.get("data_description", ""),
        training_data_size=config.get("training_size", 0),
        feature_names=list(X_test.columns),
        target_variable=config.get("target", "label"),
        sensitive_features=sensitive_cols,
        overall_metrics=overall,
        subgroup_metrics=subgroup_list,
        limitations=config.get("limitations", []),
        ethical_considerations=config.get("ethical_considerations", ""),
        risk_category=config.get("risk_category", "minimal-risk"),
        model_owner=config.get("owner", ""),
        approved_by=config.get("approved_by", ""),
        contact=config.get("contact", "")
    )


def log_model_card_to_mlflow(card: ModelCard, run_id: str):
    """Register the model card as an MLflow artifact for traceability."""
    with mlflow.start_run(run_id=run_id):
        json_path = f"/tmp/model_card_{card.model_name}_v{card.model_version}.json"
        with open(json_path, "w") as f:
            f.write(card.to_json())
        mlflow.log_artifact(json_path, artifact_path="governance")

        md_path = f"/tmp/model_card_{card.model_name}_v{card.model_version}.md"
        with open(md_path, "w") as f:
            f.write(card.to_markdown())
        mlflow.log_artifact(md_path, artifact_path="governance")

        mlflow.set_tag("governance.risk_category", card.risk_category)
        mlflow.set_tag("governance.model_owner", card.model_owner)
        mlflow.set_tag("governance.approved_by", card.approved_by)
        mlflow.set_tag("governance.has_subgroup_analysis", str(len(card.subgroup_metrics) > 0))

Fairness and Bias Detection with Fairlearn

Fairness in ML is not a single concept: there are several mathematically incompatible definitions. The AI Act does not prescribe which metric to use, but requires that high-risk systems be evaluated and documented with respect to protected groups. The two most common and complementary metrics are Demographic Parity and Equalized Odds.

Demographic Parity requires that the probability of receiving a positive outcome is equal across all groups: P(Y_pred=1 | A=0) = P(Y_pred=1 | A=1). It is the most intuitive metric but can hide real performance differences if groups have different base rates. Equalized Odds is stricter: it requires both TPR and FPR to be equal across groups, ensuring the model makes errors at the same rate regardless of group membership.

# fairness_checker.py
# Comprehensive fairness analysis with Fairlearn
# pip install fairlearn scikit-learn pandas

import pandas as pd
import numpy as np
from fairlearn.metrics import (
    MetricFrame,
    demographic_parity_difference,
    demographic_parity_ratio,
    equalized_odds_difference,
    equalized_odds_ratio,
    false_positive_rate,
    false_negative_rate,
    selection_rate
)
from fairlearn.reductions import ExponentiatedGradient, DemographicParity, EqualizedOdds
from sklearn.metrics import accuracy_score, f1_score
import mlflow


def run_fairness_analysis(
    y_true: pd.Series,
    y_pred: np.ndarray,
    sensitive_feature: pd.Series,
    y_proba: np.ndarray = None,
    threshold: float = 0.1
) -> dict:
    """
    Comprehensive fairness analysis for an ML model.

    Args:
        y_true: Ground truth labels
        y_pred: Model predictions
        sensitive_feature: Sensitive feature (e.g., gender, age_group)
        y_proba: Predicted probabilities (optional)
        threshold: Acceptability threshold for fairness differences

    Returns:
        dict with fairness metrics and compliance flags
    """
    results = {}

    # ---- 1. Demographic Parity ----
    dp_diff = demographic_parity_difference(
        y_true=y_true, y_pred=y_pred, sensitive_features=sensitive_feature
    )
    dp_ratio = demographic_parity_ratio(
        y_true=y_true, y_pred=y_pred, sensitive_features=sensitive_feature
    )
    results["demographic_parity_difference"] = float(dp_diff)
    results["demographic_parity_ratio"] = float(dp_ratio)
    results["demographic_parity_pass"] = abs(dp_diff) <= threshold

    # ---- 2. Equalized Odds ----
    eo_diff = equalized_odds_difference(
        y_true=y_true, y_pred=y_pred, sensitive_features=sensitive_feature
    )
    eo_ratio = equalized_odds_ratio(
        y_true=y_true, y_pred=y_pred, sensitive_features=sensitive_feature
    )
    results["equalized_odds_difference"] = float(eo_diff)
    results["equalized_odds_ratio"] = float(eo_ratio)
    results["equalized_odds_pass"] = abs(eo_diff) <= threshold

    # ---- 3. MetricFrame: disaggregated metrics per group ----
    metrics_dict = {
        "accuracy": accuracy_score,
        "f1": lambda y_t, y_p: f1_score(y_t, y_p, zero_division=0),
        "false_positive_rate": false_positive_rate,
        "false_negative_rate": false_negative_rate,
        "selection_rate": selection_rate,
    }

    metric_frame = MetricFrame(
        metrics=metrics_dict,
        y_true=y_true,
        y_pred=y_pred,
        sensitive_features=sensitive_feature
    )

    results["by_group"] = metric_frame.by_group.to_dict()
    results["overall"] = metric_frame.overall.to_dict()
    results["difference"] = metric_frame.difference().to_dict()
    results["ratio"] = metric_frame.ratio().to_dict()

    # ---- 4. Overall compliance flag ----
    results["is_fair"] = (
        results["demographic_parity_pass"] and
        results["equalized_odds_pass"]
    )

    # Explanatory messages
    messages = []
    if not results["demographic_parity_pass"]:
        messages.append(
            f"WARNING: Demographic Parity Difference = {dp_diff:.4f} "
            f"(threshold: {threshold}). The disadvantaged group receives "
            f"positive outcomes {abs(dp_diff)*100:.1f}% less frequently."
        )
    if not results["equalized_odds_pass"]:
        messages.append(
            f"WARNING: Equalized Odds Difference = {eo_diff:.4f} "
            f"(threshold: {threshold}). Error rates vary significantly across groups."
        )
    if results["is_fair"]:
        messages.append("OK: All fairness metrics within acceptable threshold.")

    results["messages"] = messages
    return results


def mitigate_bias(
    estimator,
    X_train: pd.DataFrame,
    y_train: pd.Series,
    sensitive_train: pd.Series,
    constraint: str = "demographic_parity"
):
    """
    Bias mitigation via Exponentiated Gradient (Fairlearn).
    Returns a fairness-aware model.

    Args:
        constraint: "demographic_parity" or "equalized_odds"
    """
    if constraint == "demographic_parity":
        fairness_constraint = DemographicParity(difference_bound=0.05)
    else:
        fairness_constraint = EqualizedOdds(difference_bound=0.05)

    mitigator = ExponentiatedGradient(
        estimator=estimator,
        constraints=fairness_constraint,
        max_iter=50,
        nu=1e-6
    )
    mitigator.fit(X_train, y_train, sensitive_features=sensitive_train)
    print(f"Mitigation complete. Ensemble size: {len(mitigator.predictors_)} classifiers.")
    return mitigator


def log_fairness_to_mlflow(fairness_results: dict, run_id: str):
    """Log fairness results to MLflow for audit trail."""
    with mlflow.start_run(run_id=run_id):
        mlflow.log_metrics({
            "fairness.demographic_parity_diff": fairness_results["demographic_parity_difference"],
            "fairness.demographic_parity_ratio": fairness_results["demographic_parity_ratio"],
            "fairness.equalized_odds_diff": fairness_results["equalized_odds_difference"],
            "fairness.equalized_odds_ratio": fairness_results["equalized_odds_ratio"],
        })
        mlflow.set_tag("fairness.is_fair", str(fairness_results["is_fair"]))

        import json
        report_path = "/tmp/fairness_report.json"
        with open(report_path, "w") as f:
            serializable = {
                k: v for k, v in fairness_results.items()
                if isinstance(v, (dict, list, bool, str, float, int))
            }
            json.dump(serializable, f, indent=2, default=float)
        mlflow.log_artifact(report_path, artifact_path="governance/fairness")

Demographic Parity vs Equalized Odds: Which to Use?

  • Demographic Parity: ideal when no group difference is expected in base rates (e.g., credit approval: approval rates should not differ between men and women regardless of actual creditworthiness). May penalize overall accuracy.
  • Equalized Odds: ideal when ground truth labels may legitimately differ across groups (e.g., medical screening: different disease rates by age group). Ensures false positives and false negatives are distributed equally.
  • Impossibility theorem: demographic parity and equalized odds cannot be satisfied simultaneously unless base rates are identical across groups. Choose the metric appropriate to your context and document it in the model card.

Explainability with SHAP: Local and Global Transparency

Explainability is not only a regulatory requirement: it is a practical necessity for debugging models, gaining stakeholder trust, and detecting latent bias. SHAP (SHapley Additive exPlanations) has become the de facto standard for ML explainability because it offers strong mathematical properties: consistency, local accuracy, and support for tree-based models (via TreeSHAP, which runs in O(TLD^2) rather than O(TL2^M) for generic models).

# explainability_shap.py
# Complete SHAP-based explainability for production ML models
# pip install shap matplotlib pandas scikit-learn

import shap
import numpy as np
import pandas as pd
import matplotlib
matplotlib.use('Agg')  # Non-interactive backend for server environments
import matplotlib.pyplot as plt
import mlflow
import json
from pathlib import Path


class MLExplainer:
    """
    SHAP-based explainability for production ML models.
    Supports TreeSHAP (XGBoost, LightGBM, RandomForest) and KernelSHAP (any model).
    """

    def __init__(self, model, model_type: str = "tree", background_data=None):
        self.model = model
        self.model_type = model_type

        if model_type == "tree":
            self.explainer = shap.TreeExplainer(model)
        elif model_type == "linear":
            self.explainer = shap.LinearExplainer(model, background_data)
        else:
            if background_data is None:
                raise ValueError("KernelSHAP requires background_data (training set sample)")
            bg_summary = shap.kmeans(background_data, 50)
            self.explainer = shap.KernelExplainer(model.predict_proba, bg_summary)

        self.shap_values = None
        self.feature_names = None

    def compute_shap_values(self, X: pd.DataFrame) -> np.ndarray:
        """Compute SHAP values for a dataset."""
        self.feature_names = list(X.columns)
        shap_output = self.explainer(X)

        if hasattr(shap_output, 'values'):
            values = shap_output.values
            if len(values.shape) == 3:  # [samples, features, classes]
                self.shap_values = values[:, :, 1]  # Positive class
            else:
                self.shap_values = values
        else:
            self.shap_values = shap_output

        return self.shap_values

    def global_importance(self, top_n: int = 15) -> pd.DataFrame:
        """
        Global feature importance: mean of absolute SHAP values.
        This is the view required by regulators (AI Act, XAI requirement).
        """
        if self.shap_values is None:
            raise RuntimeError("Call compute_shap_values() first.")

        mean_abs_shap = np.abs(self.shap_values).mean(axis=0)
        return pd.DataFrame({
            "feature": self.feature_names,
            "mean_abs_shap": mean_abs_shap
        }).sort_values("mean_abs_shap", ascending=False).head(top_n)

    def explain_single_prediction(
        self, sample: pd.Series, threshold: float = 0.5
    ) -> dict:
        """
        Local explanation for a single prediction.
        Required by AI Act for High-Risk systems: every decision must be explainable.
        """
        sample_df = pd.DataFrame([sample])
        single_shap = self.explainer(sample_df)

        if hasattr(single_shap, 'values'):
            values = single_shap.values[0]
            if len(values.shape) == 2:
                values = values[:, 1]
            base_value = float(single_shap.base_values[0])
        else:
            values = single_shap[0]
            base_value = float(self.explainer.expected_value)

        pred_proba = self.model.predict_proba(sample_df)[0, 1]
        pred_class = int(pred_proba >= threshold)

        contributions = sorted(
            zip(self.feature_names, values, sample.values),
            key=lambda x: abs(x[1]),
            reverse=True
        )

        return {
            "prediction_probability": float(pred_proba),
            "prediction_class": pred_class,
            "base_value": base_value,
            "top_contributing_features": [
                {
                    "feature": feat,
                    "shap_value": float(sv),
                    "feature_value": float(fv) if isinstance(fv, (int, float, np.number)) else str(fv),
                    "direction": "positive" if sv > 0 else "negative"
                }
                for feat, sv, fv in contributions[:10]
            ]
        }

    def log_to_mlflow(self, X_sample: pd.DataFrame, run_id: str):
        """Register explainability artifacts in MLflow for audit trail."""
        if self.shap_values is None:
            self.compute_shap_values(X_sample)

        with mlflow.start_run(run_id=run_id):
            importance = self.global_importance()
            importance_path = "/tmp/shap_importance.json"
            importance.to_json(importance_path, orient="records", indent=2)
            mlflow.log_artifact(importance_path, artifact_path="governance/explainability")

            plot_path = "/tmp/shap_summary_plot.png"
            plt.figure(figsize=(10, 8))
            shap.summary_plot(self.shap_values, X_sample, plot_type="bar",
                              show=False, max_display=15)
            plt.tight_layout()
            plt.savefig(plot_path, dpi=150, bbox_inches='tight')
            plt.close()
            mlflow.log_artifact(plot_path, artifact_path="governance/explainability")

Immutable Audit Trail for Compliance

The AI Act requires high-risk systems to automatically log relevant events during operation, with logs detailed enough to allow supervisory authorities to verify compliance. These logs must be immutable, timestamped, and traceable. A robust audit trail system has three levels: individual prediction logs, model stage transition logs, and governance event logs.

# audit_logger.py
# AI Act-compliant audit trail for production ML models
# Uses append-only log file with SHA-256 hashing for tamper detection

import hashlib
import json
import logging
import datetime
import uuid
from typing import Optional
import structlog  # pip install structlog
import mlflow


structlog.configure(
    processors=[
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.processors.add_log_level,
        structlog.processors.JSONRenderer()
    ]
)

audit_log = structlog.get_logger("audit")


class MLAuditLogger:
    """
    Audit trail system for production ML models.
    Every log entry includes a SHA-256 hash of its content for tamper detection.
    """

    def __init__(self, model_name: str, model_version: str, log_file: str = None):
        self.model_name = model_name
        self.model_version = model_version
        self.log_file = log_file or f"audit_{model_name}_v{model_version}.jsonl"

        # Append-only file logger
        self.file_logger = logging.getLogger(f"audit.{model_name}")
        if not self.file_logger.handlers:
            handler = logging.FileHandler(self.log_file, mode='a')
            handler.setFormatter(logging.Formatter('%(message)s'))
            self.file_logger.addHandler(handler)
            self.file_logger.setLevel(logging.INFO)

    def _compute_hash(self, entry: dict) -> str:
        content = json.dumps(entry, sort_keys=True, ensure_ascii=False)
        return hashlib.sha256(content.encode()).hexdigest()

    def log_prediction(
        self,
        request_id: str,
        input_features: dict,
        prediction: float,
        prediction_class: int,
        shap_explanation: Optional[dict] = None,
        user_id: Optional[str] = None,
        session_id: Optional[str] = None
    ):
        """
        Log a single prediction.
        For AI Act High-Risk: EVERY decision must be recorded.
        """
        entry = {
            "event_type": "PREDICTION",
            "event_id": str(uuid.uuid4()),
            "timestamp": datetime.datetime.utcnow().isoformat() + "Z",
            "model_name": self.model_name,
            "model_version": self.model_version,
            "request_id": request_id,
            "user_id": user_id or "anonymous",
            "session_id": session_id,
            "input": input_features,
            "output": {
                "prediction_probability": float(prediction),
                "prediction_class": int(prediction_class),
                "decision": "POSITIVE" if prediction_class == 1 else "NEGATIVE"
            },
            "explanation": shap_explanation,
        }
        entry["content_hash"] = self._compute_hash(
            {k: v for k, v in entry.items() if k != "content_hash"}
        )
        self.file_logger.info(json.dumps(entry))
        return entry["event_id"]

    def log_model_transition(
        self,
        from_stage: str,
        to_stage: str,
        approved_by: str,
        run_id: str,
        reason: str = "",
        ticket_id: Optional[str] = None
    ):
        """
        Log a model stage transition (Staging -> Production).
        Provides the change management trail required for regulated industries.
        """
        entry = {
            "event_type": "MODEL_STAGE_TRANSITION",
            "event_id": str(uuid.uuid4()),
            "timestamp": datetime.datetime.utcnow().isoformat() + "Z",
            "model_name": self.model_name,
            "model_version": self.model_version,
            "from_stage": from_stage,
            "to_stage": to_stage,
            "approved_by": approved_by,
            "mlflow_run_id": run_id,
            "reason": reason,
            "ticket_id": ticket_id,
        }
        entry["content_hash"] = self._compute_hash(
            {k: v for k, v in entry.items() if k != "content_hash"}
        )
        self.file_logger.info(json.dumps(entry))
        audit_log.info("model_transition", **entry)
        return entry["event_id"]

    def verify_log_integrity(self) -> dict:
        """
        Verify audit log integrity by checking SHA-256 hashes.
        Call periodically or before audit inspections.
        """
        total = 0
        passed = 0
        failed_entries = []

        with open(self.log_file, "r") as f:
            for line in f:
                if not line.strip():
                    continue
                try:
                    entry = json.loads(line)
                    stored_hash = entry.pop("content_hash", None)
                    recomputed = self._compute_hash(entry)
                    total += 1
                    if stored_hash == recomputed:
                        passed += 1
                    else:
                        failed_entries.append(entry.get("event_id", "unknown"))
                except json.JSONDecodeError:
                    failed_entries.append("unparseable_entry")
                    total += 1

        return {
            "total_entries": total,
            "integrity_passed": passed,
            "integrity_failed": len(failed_entries),
            "tampered_entries": failed_entries,
            "log_is_clean": len(failed_entries) == 0
        }

Practical Governance for SMEs: Budget Under EUR 5,000/Year

Not every organization has the resources of a large enterprise. The following approach implements compliant governance with an entirely open-source stack at minimal cost, suitable for teams of 2-10 people.

Governance Need Open-Source Tool Annual Cost Effort
Model documentation Model Cards (custom Python generator) EUR 0 4 hours setup
Experiment/audit trail MLflow self-hosted (Hetzner VPS) EUR 191/year 1 day setup
Fairness analysis Fairlearn EUR 0 2 hours per model
Explainability SHAP EUR 0 2 hours per model
Prediction audit log structlog + local file EUR 0 2 hours integration
Monitoring dashboard Grafana + Prometheus EUR 0 (same VPS) 1 day setup
Total - EUR 191/year ~3 days

Governance Checklist Before Deploy


## ML Governance Checklist - Pre-Deploy

### Model Card
- [ ] Model card generated and includes intended use
- [ ] Out-of-scope uses documented
- [ ] Known limitations listed
- [ ] Risk category assigned (AI Act taxonomy)
- [ ] Model card reviewed and approved by ML lead
- [ ] Model card version-controlled in Git

### Fairness
- [ ] Sensitive features identified
- [ ] Fairness analysis run (Demographic Parity + Equalized Odds)
- [ ] All fairness checks pass (diff < 0.10)
- [ ] OR: if fairness gap exists, mitigation applied and documented
- [ ] Subgroup performance metrics in model card

### Explainability
- [ ] SHAP global importance computed and logged to MLflow
- [ ] Top-10 features documented in model card
- [ ] Local explanation endpoint available in API (for High-Risk systems)
- [ ] SHAP summary plot saved as governance artifact

### Audit Trail
- [ ] Prediction logging enabled in API
- [ ] Log integrity check passes on pre-production environment
- [ ] Log retention policy defined (min 6 months recommended)
- [ ] Stage transition logged with approver name and ticket ID

### Compliance
- [ ] AI Act risk category confirmed with legal team
- [ ] If High-Risk: human oversight mechanism implemented
- [ ] If High-Risk: technical documentation complete
- [ ] Data governance: training data source documented

Conclusions: Governance Starts on Day One

ML governance is not a bureaucratic obstacle: it is a competitive advantage. Teams that build governance infrastructure from the start deploy faster, with greater confidence, and with lower compliance risk. The EU AI Act is a significant regulatory shift, but the open-source tooling available in 2026 makes compliance accessible even for small teams with limited budgets.

The four pillars we covered in this article, model cards, fairness analysis, SHAP explainability, and immutable audit trails, form the minimum viable governance framework for any ML system in production. Implementing them from the beginning costs far less than retrofitting them under regulatory pressure.

Continue the Series

Try These Tools

Free developer tools related to this topic.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects