What You'll Build
In this exercise you will build a complete Apache Airflow DAG that orchestrates an end-to-end ML pipeline for IPL player churn prediction — identifying which players are at risk of declining performance or leaving the squad. The pipeline covers five production tasks in sequence: ingest_ipl_data ingests raw IPL batting statistics from a CSV file, feature_engineering computes predictive features using the cricket_feature_pipeline function, train_model fits a GradientBoostingClassifier on the engineered features, register_model logs the trained model to MLflow and registers it in the Model Registry under the champion slot, and notify_team sends a Slack-style notification with the training results. You will wire all five tasks using the Airflow TaskFlow API and the >> operator on a daily schedule.
Prerequisites
Before starting, ensure you have Python 3.9 or later, Apache Airflow 2.5 or newer, and pip available. You will install four additional packages: scikit-learn for the GradientBoostingClassifier, mlflow for experiment tracking and model registration, pandas and numpy for data manipulation, and requests for the Slack webhook notification. Airflow must be initialised with airflow db init and at least one webserver and scheduler process running. Familiarity with Airflow's TaskFlow API decorator pattern from earlier reading lessons is assumed. No external cloud credentials are required — MLflow runs locally and the Slack notification uses a stub that prints to the console.
Setup
# Step 0: Install dependencies and create project structure
# Run in terminal inside your virtualenv:
# pip install apache-airflow==2.8.1 scikit-learn mlflow pandas numpy requests
# Verify key imports
import airflow
import mlflow
import sklearn
import pandas as pd
import numpy as np
print(f"airflow version: {airflow.__version__}")
print(f"mlflow version: {mlflow.__version__}")
print(f"sklearn version: {sklearn.__version__}")
print(f"pandas version: {pd.__version__}")
print("All dependencies verified.")
# Create project directories
import os
os.makedirs('/tmp/ipl_pipeline/data', exist_ok=True)
os.makedirs('/tmp/ipl_pipeline/models', exist_ok=True)
os.makedirs('/tmp/ipl_pipeline/staging', exist_ok=True)
# Generate sample IPL player dataset for the exercise
np.random.seed(42)
n_players = 200
ipl_players_dag = pd.DataFrame({
'match_id': [f'IPL2024_{i:04d}' for i in range(n_players)],
'player_name': np.random.choice(
['Rohit Sharma', 'Virat Kohli', 'Shubman Gill',
'MS Dhoni', 'Jasprit Bumrah', 'KL Rahul',
'Hardik Pandya', 'Suryakumar Yadav'],
n_players
),
'batting_average': np.random.normal(35.0, 12.0, n_players).clip(0, 80),
'innings_count': np.random.randint(5, 30, n_players),
'strike_rate_raw': np.random.normal(130.0, 20.0, n_players).clip(70, 220),
'boundaries_hit': np.random.randint(5, 80, n_players),
'total_runs': np.random.randint(80, 700, n_players),
'matches_played': np.random.randint(10, 50, n_players),
# Label: 1 = at risk of performance churn
'performance_churn': np.random.binomial(1, 0.35, n_players),
})
ipl_players_dag.to_csv('/tmp/ipl_pipeline/data/ipl_players_raw.csv', index=False)
print(f"Sample dataset: {ipl_players_dag.shape} saved to /tmp/ipl_pipeline/data/ipl_players_raw.csv")
print(ipl_players_dag.head(3).to_string())
Step 1: Define the DAG and Tasks
The DAG scaffolding defines the metadata, schedule, and default arguments that govern all five tasks. Using the @dag decorator from Airflow's TaskFlow API keeps the pipeline code readable and testable — each @task-decorated function becomes an Airflow task with automatic XCom serialization of its return value. The daily schedule ensures the pipeline refreshes the model every 24 hours with new match data. Setting catchup=False prevents Airflow from backfilling all missed days when the DAG is first enabled, which would create hundreds of redundant training runs. Setting retries=1 with a 5-minute retry delay handles transient failures like temporary file system unavailability without requiring manual re-triggers.
# dags/ipl_churn_pipeline.py — DAG scaffold and shared constants
from __future__ import annotations
from datetime import datetime, timedelta
from airflow.decorators import dag, task
import os
# ── Shared path constants used by all five tasks ──────────────────────────────
RAW_DATA_PATH = '/tmp/ipl_pipeline/data/ipl_players_raw.csv'
STAGING_JSON = '/tmp/ipl_pipeline/staging/ipl_players_staging.json'
FEATURES_PARQUET = '/tmp/ipl_pipeline/staging/ipl_features.parquet'
METRICS_JSON = '/tmp/ipl_pipeline/staging/training_metrics.json'
MODEL_DIR = '/tmp/ipl_pipeline/models'
MLFLOW_TRACKING = 'sqlite:////tmp/ipl_pipeline/mlflow.db' # local SQLite backend
MLFLOW_MODEL_NAME = 'ipl_player_churn_model'
# ── Default arguments applied to all tasks ───────────────────────────────────
default_args = {
'owner': 'mlops_team',
'depends_on_past': False,
'email_on_failure': False,
'email_on_retry': False,
'retries': 1,
'retry_delay': timedelta(minutes=5),
}
@dag(
dag_id='ipl_player_churn_pipeline',
description='Daily IPL player churn prediction pipeline: '
'ingest → features → train → register → notify',
schedule_interval='@daily',
start_date=datetime(2024, 1, 1),
catchup=False, # never backfill missed runs
default_args=default_args,
tags=['mlops', 'cricket', 'churn', 'classification'],
)
def ipl_players_dag():
"""
End-to-end Airflow ML pipeline for IPL player performance churn prediction.
Task graph:
ingest_ipl_data
>> feature_engineering
>> train_model
>> register_model
>> notify_team
"""
# ── Task definitions live inside the @dag function (TaskFlow pattern) ─────
# Each task is defined as a nested @task-decorated function.
# TaskFlow automatically handles XCom push/pull via function return values.
pass # Task bodies defined in Steps 2-5 below
# Instantiate DAG (Airflow discovers this object)
ipl_churn_pipeline = ipl_players_dag()
Step 2: Data Ingestion Task
The ingest_ipl_data task is the pipeline's entry point. It reads raw IPL player batting statistics from the CSV file, performs lightweight structural validation to confirm mandatory columns are present and the file is non-empty, and writes the DataFrame to a staging JSON file that downstream tasks can consume. Keeping ingestion separate from validation and feature engineering follows the single-responsibility principle — if the data source changes from CSV to S3, only this task needs modification. The task returns the path to the staging file, which Airflow's XCom system passes automatically to the next task in the chain.
# dags/ipl_churn_pipeline.py — Task 1: ingest_ipl_data
# (nested inside the @dag function body)
@task(task_id='ingest_ipl_data')
def ingest_ipl_data() -> str:
"""
Read raw IPL player statistics CSV; validate structure; write to staging JSON.
Returns: path to staging JSON file for downstream tasks.
"""
import pandas as pd
import os
if not os.path.exists(RAW_DATA_PATH):
raise FileNotFoundError(
f"Raw IPL data not found at {RAW_DATA_PATH}. "
"Ensure the upstream data pipeline has deposited the file."
)
ipl_players_dag_df = pd.read_csv(RAW_DATA_PATH)
# Structural validation — fail fast before any processing
required_columns = [
'match_id', 'player_name', 'batting_average',
'innings_count', 'strike_rate_raw', 'boundaries_hit',
'total_runs', 'matches_played', 'performance_churn'
]
missing_cols = [c for c in required_columns if c not in ipl_players_dag_df.columns]
if missing_cols:
raise ValueError(
f"Ingested IPL data is missing required columns: {missing_cols}. "
f"Available columns: {list(ipl_players_dag_df.columns)}"
)
if ipl_players_dag_df.empty:
raise ValueError('Ingested IPL player dataset is empty — nothing to process.')
churn_rate = ipl_players_dag_df['performance_churn'].mean()
print(f"Ingested {len(ipl_players_dag_df)} IPL player records.")
print(f"Performance churn rate in dataset: {churn_rate:.1%}")
print(ipl_players_dag_df[['match_id', 'player_name', 'batting_average', 'performance_churn']].head(5).to_string())
# Persist for downstream tasks
os.makedirs(os.path.dirname(STAGING_JSON), exist_ok=True)
ipl_players_dag_df.to_json(STAGING_JSON, orient='records', indent=2)
print(f"Staged data written to {STAGING_JSON}")
return STAGING_JSON
Step 3: Feature Engineering Task
The feature_engineering task transforms raw batting statistics into predictive features using the cricket_feature_pipeline function — a modular transformation function that encapsulates all feature logic. Three derived features are computed: batting_form_score normalizes batting average against the squad's maximum to give a relative form indicator, innings_density divides total runs by innings count to measure run-scoring consistency per innings, and boundary_efficiency measures what fraction of total runs came from boundaries as a proxy for aggressive intent. The feature matrix is serialized to Parquet for efficient reading in the training task.
# dags/ipl_churn_pipeline.py — cricket_feature_pipeline helper + Task 2
import pandas as pd
import numpy as np
def cricket_feature_pipeline(ipl_players_dag_df: pd.DataFrame) -> pd.DataFrame:
"""
Modular feature engineering function for IPL player churn prediction.
Encapsulates all transformation logic; call from within the Airflow task
and also independently in unit tests.
"""
df = ipl_players_dag_df.copy()
# Feature 1: batting_form_score
# Normalised batting average — how strong is this player relative to squad peers?
max_batting_average = df['batting_average'].max()
df['batting_form_score'] = (df['batting_average'] / max_batting_average).clip(0, 1)
# Feature 2: innings_density
# Runs per innings — rewards consistent scorers over occasional big hitters
df['innings_density'] = (
df['total_runs'] / df['innings_count'].clip(lower=1)
).clip(0, 200)
# Feature 3: boundary_efficiency
# Fraction of runs from boundaries — measures aggressive batting style
# boundaries_hit are fours (4 runs each)
boundary_runs = df['boundaries_hit'] * 4
df['boundary_efficiency'] = (
boundary_runs / df['total_runs'].clip(lower=1)
).clip(0, 1)
# Feature 4: experience_index
# Normalised matches played — veterans vs newcomers behave differently under pressure
max_matches = df['matches_played'].max()
df['experience_index'] = (df['matches_played'] / max_matches).clip(0, 1)
return df
@task(task_id='feature_engineering')
def feature_engineering(staging_path: str) -> str:
"""
Load staged IPL player data, run cricket_feature_pipeline,
write feature matrix to Parquet. Returns features file path.
"""
ipl_players_dag_df = pd.read_json(staging_path, orient='records')
enriched_df = cricket_feature_pipeline(ipl_players_dag_df)
# Select final feature columns + label
feature_cols = [
'match_id',
'batting_average',
'innings_count',
'batting_form_score',
'innings_density',
'boundary_efficiency',
'experience_index',
'performance_churn' # label
]
feature_matrix = enriched_df[feature_cols].copy()
# Ensure no NaN or Inf values before training
feature_matrix.replace([np.inf, -np.inf], np.nan, inplace=True)
n_null_before = feature_matrix.isnull().sum().sum()
feature_matrix.fillna(0.0, inplace=True)
if n_null_before > 0:
print(f" Warning: {n_null_before} NaN values filled with 0.0")
os.makedirs(os.path.dirname(FEATURES_PARQUET), exist_ok=True)
feature_matrix.to_parquet(FEATURES_PARQUET, index=False)
print(f"Feature matrix: {feature_matrix.shape} — saved to {FEATURES_PARQUET}")
print("Feature means:")
for col in ['batting_form_score', 'innings_density', 'boundary_efficiency', 'experience_index']:
print(f" {col}: {feature_matrix[col].mean():.4f}")
return FEATURES_PARQUET
Step 4: Train and Register Model
The train_model task fits a GradientBoostingClassifier on the engineered feature matrix, evaluates it with AUC-ROC and F1 score, and logs all parameters and metrics to MLflow for experiment tracking. The subsequent register_model task queries the MLflow Model Registry to check whether there is a currently registered champion model. If the new model outperforms the champion on AUC-ROC by at least 0.5%, it is promoted to the Production stage in the registry. This two-task split cleanly separates training concerns from promotion concerns, matching the champion-challenger pattern discussed in the alerting lesson. The register task returns a summary dictionary that the notify task consumes.
# dags/ipl_churn_pipeline.py — Tasks 3 and 4: train_model + register_model
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import roc_auc_score, f1_score
import mlflow
import mlflow.sklearn
import json
@task(task_id='train_model')
def train_model(features_path: str) -> str:
"""
Train GradientBoostingClassifier on IPL churn features.
Log parameters, metrics, and model artifact to MLflow.
Returns: path to training metrics JSON.
"""
feature_matrix = pd.read_parquet(features_path)
predictor_cols = [
'batting_average', 'innings_count',
'batting_form_score', 'innings_density',
'boundary_efficiency', 'experience_index'
]
X = feature_matrix[predictor_cols].values
y = feature_matrix['performance_churn'].values
X_train, X_val, y_train, y_val = train_test_split(
X, y, test_size=0.2, random_state=42,
stratify=y if len(np.unique(y)) > 1 else None
)
mlflow.set_tracking_uri(MLFLOW_TRACKING)
mlflow.set_experiment('ipl_player_churn_prediction')
with mlflow.start_run(run_name='ipl_churn_gbm') as run:
# Model hyperparameters
n_estimators = 150
learning_rate = 0.08
max_depth = 4
model = GradientBoostingClassifier(
n_estimators=n_estimators,
learning_rate=learning_rate,
max_depth=max_depth,
random_state=42
)
model.fit(X_train, y_train)
# Evaluation metrics
y_proba = model.predict_proba(X_val)[:, 1]
y_pred = (y_proba > 0.5).astype(int)
val_auc = float(roc_auc_score(y_val, y_proba))
val_f1 = float(f1_score(y_val, y_pred, zero_division=0))
# Log to MLflow
mlflow.log_params({
'n_estimators': n_estimators,
'learning_rate': learning_rate,
'max_depth': max_depth,
'n_features': len(predictor_cols),
})
mlflow.log_metrics({'val_auc': round(val_auc, 4), 'val_f1': round(val_f1, 4)})
mlflow.sklearn.log_model(
sk_model=model,
artifact_path='ipl_churn_model',
registered_model_name=MLFLOW_MODEL_NAME
)
run_id = run.info.run_id
print(f"Training complete — AUC: {val_auc:.4f}, F1: {val_f1:.4f}, run_id: {run_id}")
training_metrics = {
'run_id': run_id,
'val_auc': round(val_auc, 4),
'val_f1': round(val_f1, 4),
'n_train': int(len(X_train)),
'n_val': int(len(X_val)),
'n_estimators': n_estimators,
'learning_rate': learning_rate,
}
os.makedirs(os.path.dirname(METRICS_JSON), exist_ok=True)
with open(METRICS_JSON, 'w') as fh:
json.dump(training_metrics, fh, indent=2)
return METRICS_JSON
@task(task_id='register_model')
def register_model(metrics_path: str) -> dict:
"""
Compare challenger vs champion in MLflow Model Registry.
Promote challenger to Production if AUC improves by >= 0.005.
Returns registration summary dict for the notify_team task.
"""
with open(metrics_path) as fh:
challenger_metrics = json.load(fh)
challenger_auc = challenger_metrics['val_auc']
challenger_run_id = challenger_metrics['run_id']
mlflow.set_tracking_uri(MLFLOW_TRACKING)
client = mlflow.tracking.MlflowClient()
# Fetch current Production champion AUC from registry
champion_auc = None
try:
production_versions = client.get_latest_versions(
MLFLOW_MODEL_NAME, stages=['Production']
)
if production_versions:
champion_run = client.get_run(production_versions[0].run_id)
champion_auc = float(champion_run.data.metrics.get('val_auc', 0.0))
except Exception:
champion_auc = None # No champion exists yet — first run
promoted = False
reason = ''
min_improvement = 0.005
if champion_auc is None:
# No champion — promote challenger unconditionally
client.transition_model_version_stage(
name=MLFLOW_MODEL_NAME,
version=client.get_latest_versions(MLFLOW_MODEL_NAME)[0].version,
stage='Production'
)
promoted = True
reason = 'First registration — no existing champion'
elif challenger_auc > champion_auc + min_improvement:
client.transition_model_version_stage(
name=MLFLOW_MODEL_NAME,
version=client.get_latest_versions(MLFLOW_MODEL_NAME)[0].version,
stage='Production'
)
promoted = True
reason = f'Challenger AUC {challenger_auc:.4f} > champion {champion_auc:.4f} + {min_improvement}'
else:
reason = f'Challenger AUC {challenger_auc:.4f} did not beat champion {champion_auc} + {min_improvement}'
registration_summary = {
'model_name': MLFLOW_MODEL_NAME,
'run_id': challenger_run_id,
'challenger_auc': challenger_auc,
'champion_auc': champion_auc,
'promoted': promoted,
'reason': reason,
'val_f1': challenger_metrics['val_f1'],
}
print(f"Model registration: {'PROMOTED' if promoted else 'NOT PROMOTED'}")
print(f"Reason: {reason}")
return registration_summary
Testing and Verification
After completing the full DAG file with the notify_team task and task wiring, verify the pipeline end-to-end using the Airflow CLI. Test each task individually first with airflow tasks test before triggering a complete run. Check that MLflow's local tracking database is populated with experiment runs, that the model appears in the Model Registry under the Production stage after the first successful run, and that the notify_team task logs the expected summary message. Use the verification script below to automate the pipeline logic checks outside of Airflow's scheduler, enabling rapid iteration during development.
# notify_team task + complete DAG wiring + verification script
import json
import os
from airflow.decorators import task
@task(task_id='notify_team')
def notify_team(registration_summary: dict) -> None:
"""
Send training results notification to the coaching staff channel.
In production: POST to Slack webhook URL stored in Airflow Variables.
In development: prints formatted summary to Airflow task logs.
"""
promoted = registration_summary['promoted']
challenger = registration_summary['challenger_auc']
champion = registration_summary['champion_auc']
val_f1 = registration_summary['val_f1']
model_name = registration_summary['model_name']
run_id = registration_summary['run_id'][:8] # abbreviated for readability
status_emoji = '[PROMOTED]' if promoted else '[NOT PROMOTED]'
notification_message = (
f"{status_emoji} IPL Player Churn Model — Daily Training Complete\n"
f" Model: {model_name}\n"
f" Run ID: {run_id}\n"
f" Challenger AUC: {challenger:.4f} | Champion AUC: {champion}\n"
f" Val F1: {val_f1:.4f}\n"
f" Promotion decision: {registration_summary['reason']}\n"
f" Dashboard: http://localhost:5000 (MLflow tracking)"
)
# Production: POST to Slack webhook
# import requests
# slack_webhook_url = Variable.get('slack_mlops_webhook_url')
# requests.post(slack_webhook_url, json={'text': notification_message})
print("=" * 60)
print(notification_message)
print("=" * 60)
# ── Complete DAG function body (replace 'pass' in the scaffold) ───────────────
# The five tasks are wired inside @dag via TaskFlow data passing:
#
# def ipl_players_dag():
# staging_path = ingest_ipl_data()
# features_path = feature_engineering(staging_path)
# metrics_path = train_model(features_path)
# registration_summary = register_model(metrics_path)
# notify_team(registration_summary)
# ── Standalone verification script (run outside Airflow during development) ──
def run_pipeline_standalone():
"""Execute the full pipeline logic outside Airflow for rapid testing."""
import pandas as pd
import numpy as np
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import roc_auc_score, f1_score
print("=== IPL Churn Pipeline — Standalone Verification ===")
# Step 1: Load data
ipl_players_dag_df = pd.read_csv('/tmp/ipl_pipeline/data/ipl_players_raw.csv')
print(f"[1/5] Ingested {len(ipl_players_dag_df)} records")
assert not ipl_players_dag_df.empty, 'Data is empty'
assert 'performance_churn' in ipl_players_dag_df.columns, 'Missing label column'
# Step 2: Feature engineering
ipl_players_dag_df['batting_form_score'] = (
ipl_players_dag_df['batting_average'] / ipl_players_dag_df['batting_average'].max()
)
ipl_players_dag_df['innings_density'] = (
ipl_players_dag_df['total_runs'] / ipl_players_dag_df['innings_count'].clip(1)
)
ipl_players_dag_df['boundary_efficiency'] = (
ipl_players_dag_df['boundaries_hit'] * 4 / ipl_players_dag_df['total_runs'].clip(1)
)
ipl_players_dag_df['experience_index'] = (
ipl_players_dag_df['matches_played'] / ipl_players_dag_df['matches_played'].max()
)
print(f"[2/5] Feature engineering complete — batting_form_score mean: "
f"{ipl_players_dag_df['batting_form_score'].mean():.4f}")
# Step 3: Train model
predictor_cols = ['batting_average', 'innings_count', 'batting_form_score',
'innings_density', 'boundary_efficiency', 'experience_index']
X = ipl_players_dag_df[predictor_cols].fillna(0).values
y = ipl_players_dag_df['performance_churn'].values
X_train, X_val, y_train, y_val = train_test_split(X, y, test_size=0.2, random_state=42)
model = GradientBoostingClassifier(n_estimators=150, learning_rate=0.08, random_state=42)
model.fit(X_train, y_train)
val_auc = roc_auc_score(y_val, model.predict_proba(X_val)[:, 1])
val_f1 = f1_score(y_val, model.predict(X_val), zero_division=0)
print(f"[3/5] Model trained — AUC: {val_auc:.4f}, F1: {val_f1:.4f}")
assert val_auc > 0.5, f'AUC {val_auc:.4f} is not better than random'
# Step 4: Simulate registration (no MLflow in standalone)
simulated_registration = {
'model_name': MLFLOW_MODEL_NAME, 'run_id': 'standalone_test',
'challenger_auc': round(val_auc, 4), 'champion_auc': 0.72,
'promoted': val_auc > 0.72 + 0.005,
'reason': 'standalone verification run', 'val_f1': round(val_f1, 4)
}
print(f"[4/5] Registration: {'PROMOTED' if simulated_registration['promoted'] else 'NOT PROMOTED'}")
# Step 5: Notification
print(f"[5/5] Team notification: AUC={val_auc:.4f}, F1={val_f1:.4f}")
print("=== All 5 pipeline steps verified successfully ===")
print("Safe to deploy to Airflow: copy ipl_churn_pipeline.py to your dags/ directory")
print("Then run: airflow dags trigger ipl_player_churn_pipeline")
# Run standalone verification
run_pipeline_standalone()
Warning: When using MLflow's Model Registry with a local SQLite backend (sqlite:////tmp/...), concurrent Airflow tasks writing to the same database will cause database lock errors. In production, always point MLFLOW_TRACKING_URI to a PostgreSQL or MySQL backend that handles concurrent writes correctly. For local development, this exercise uses sequential tasks so SQLite is safe, but never use SQLite for multi-worker Airflow setups running parallel training jobs.
Pro Tip
Use Airflow Variables (Admin > Variables in the UI) to store all configurable parameters outside the DAG code — RAW_DATA_PATH, MLFLOW_TRACKING_URI, Slack webhook URL, and the minimum AUC improvement threshold. Retrieve them inside tasks with Variable.get('ipl_raw_data_path', default_var='...'). This makes promoting the DAG across environments (dev > staging > prod) painless: each environment's Airflow instance holds its own Variable values pointing to the right data sources and thresholds without any code changes.
- You built a five-task Airflow DAG using the TaskFlow API covering the full ML lifecycle: ingest_ipl_data, feature_engineering, train_model, register_model, and notify_team, wired with the >> operator.
- The cricket_feature_pipeline function encapsulates all feature engineering logic as a testable, reusable unit — call it from the Airflow task and independently in unit tests without Airflow overhead.
- GradientBoostingClassifier logged to MLflow with mlflow.sklearn.log_model enables experiment tracking, parameter logging, and model artifact versioning in a single context manager block.
- The champion-challenger register_model task queries the MLflow Model Registry for the current Production model's AUC and only promotes the challenger if it improves by at least 0.5%, preventing regressions from bad retraining runs.
- The notify_team task decouples stakeholder communication from training logic — in production, swap the print statement for a Slack webhook POST using a URL stored in an Airflow Variable, requiring zero code changes to the training tasks.
- Setting catchup=False on a daily-scheduled DAG prevents Airflow from spawning hundreds of backfill runs when the DAG is first enabled — critical for ML pipelines where each run consumes significant compute resources.