yusufcalisir's picture
deploy: Hugging Face space upload
eb0499d
Raw History Blame Contribute Delete
12.7 kB
"""Celery tasks for running simulations asynchronously.
Simulations are CPU-intensive (PyTorch training) and can take minutes.
They run in Celery workers to avoid blocking the FastAPI event loop.
"""
from __future__ import annotations
import json
import logging
import math
from dataclasses import asdict
from typing import Any
from app.infrastructure.celery_app import celery_app
logger = logging.getLogger(__name__)
def _progress_callback(simulation_id: str, event_type: str, data: dict[str, Any]) -> None:
"""Progress callback that stores updates in Redis.
This runs inside the Celery worker process. It caches progress
so the API can serve real-time status without hitting the database.
"""
import redis as sync_redis
from app.config import get_settings
settings = get_settings()
r = sync_redis.from_url(settings.redis_url, decode_responses=True)
# Cache latest progress
progress_key = f"simulation:{simulation_id}:progress"
r.set(
progress_key,
json.dumps(
{
"event_type": event_type,
"data": data,
}
),
ex=3600,
)
# Publish to pub/sub for WebSocket consumers
channel = f"training:{simulation_id}"
r.publish(
channel,
json.dumps(
{
"event_type": event_type,
"data": data,
}
),
)
# Store event in a list for clients that connect after events happened
events_key = f"simulation:{simulation_id}:events"
r.rpush(
events_key,
json.dumps(
{
"event_type": event_type,
"data": data,
}
),
)
r.expire(events_key, 3600)
@celery_app.task(bind=True, name="run_simulation", max_retries=0)
def run_simulation_task(self: Any, config_dict: dict) -> dict:
"""Execute a full federated learning simulation.
This task is dispatched by the POST /simulations endpoint.
It runs the entire pipeline: data generation → local training →
federated training → evaluation → comparison.
Args:
config_dict: Serialized SimulationConfig.
Returns:
Serialized simulation results.
"""
from app.application.services.data_generator import DataGenerator
from app.application.services.fl_engine import FederatedLearningEngine
from app.application.services.metrics_service import MetricsService
from app.application.services.model_service import ModelService
from app.application.services.privacy_service import PrivacyService
from app.application.services.simulation_service import SimulationService
from app.config import get_settings
from app.domain.value_objects import SimulationConfig
settings = get_settings()
task_id = self.request.id
logger.info("Starting simulation task %s", task_id)
# Reconstruct config
config = SimulationConfig(**config_dict)
# Build services (no database - results are cached in Redis
# and persisted by the API when the task completes)
model_service = ModelService(settings)
privacy_service = PrivacyService()
fl_engine = FederatedLearningEngine(settings, model_service, privacy_service)
data_generator = DataGenerator()
metrics_service = MetricsService()
simulation_service = SimulationService(
settings=settings,
simulation_repo=None, # No DB access from worker
bank_repo=None,
metrics_repo=None,
data_generator=data_generator,
fl_engine=fl_engine,
metrics_service=metrics_service,
model_service=model_service,
)
# Run the simulation
simulation = simulation_service.run_simulation(
config=config,
progress_callback=_progress_callback,
)
# Serialize results
result: dict[str, Any] = {
"id": simulation.id,
"status": simulation.status.value,
"current_round": simulation.current_round,
"total_rounds": simulation.total_rounds,
"created_at": simulation.created_at.isoformat() if simulation.created_at else None,
"started_at": simulation.started_at.isoformat() if simulation.started_at else None,
"completed_at": simulation.completed_at.isoformat() if simulation.completed_at else None,
"duration_seconds": simulation.duration_seconds,
"error_message": simulation.error_message,
"banks": [],
}
for bank in simulation.banks:
bank_dict: dict[str, Any] = {
"id": bank.id,
"name": bank.name,
"tier": bank.tier.value,
"fraud_ratio": bank.fraud_ratio,
"num_transactions": bank.num_transactions,
"status": bank.status.value,
}
if bank.data_profile:
bank_dict["data_profile"] = asdict(bank.data_profile)
if bank.local_metrics:
bank_dict["local_metrics"] = asdict(bank.local_metrics)
if bank.federated_metrics:
bank_dict["federated_metrics"] = asdict(bank.federated_metrics)
if bank.improvement:
bank_dict["improvement"] = bank.improvement
result["banks"].append(bank_dict)
logger.info("Simulation task %s completed: %s", task_id, simulation.status.value)
return result
@celery_app.task(bind=True, name="execute_automated_retraining_task", max_retries=1)
def execute_automated_retraining_task(
self: Any,
bank_id: str = "bank_alpha",
trigger_reasons: list[str] | None = None,
auc_gate_threshold: float = 0.70,
X_val: Any = None,
y_val: Any = None,
X_train: Any = None,
y_train: Any = None,
) -> dict[str, Any]:
"""Executes asynchronous automated background model retraining workflow.
Workflow:
1. Validates presence and schema of training and holdout validation partitions.
2. Executes PyTorch model training loop (optimizer steps).
3. Applies Differential Privacy (L2 clipping + calibrated noise).
4. Evaluates model accuracy and ROC-AUC quality gate against empirical validation data.
5. Compresses encrypted parameter update payload for gRPC streaming transport.
"""
import zlib
import numpy as np
from app.application.services.model_service import ModelService
from app.application.services.privacy_service import PrivacyService
from app.config import get_settings
task_id = self.request.id or "in_process_task"
logger.info(
"Starting automated retraining worker task %s for bank: %s (Triggers: %s)...",
task_id,
bank_id,
trigger_reasons,
)
settings = get_settings()
model_service = ModelService(settings)
privacy_service = PrivacyService()
# Step 1: Validate presence of training data (fail-closed if no training data is present)
if X_train is None or y_train is None:
logger.warning(
"Automated retraining candidate for %s lacks training data. Task REJECTED (fail-closed).",
bank_id,
)
return {
"task_id": task_id,
"bank_id": bank_id,
"status": "REJECTED_MISSING_TRAINING_DATA",
"quality_gate_passed": False,
"auc_roc": None,
"auc_roc_defined": False,
"auc_gate_threshold": auc_gate_threshold,
"trigger_reasons": trigger_reasons,
}
try:
X_train_arr = np.asarray(X_train, dtype=np.float32)
y_train_arr = np.asarray(y_train, dtype=np.float32)
if len(X_train_arr) == 0 or len(y_train_arr) == 0 or len(X_train_arr) != len(y_train_arr):
raise ValueError("Training data arrays must be non-empty and matching in length.")
except Exception as exc:
logger.warning("Invalid training data provided to retraining task for %s: %s", bank_id, exc)
return {
"task_id": task_id,
"bank_id": bank_id,
"status": "REJECTED_INVALID_TRAINING_DATA",
"quality_gate_passed": False,
"auc_roc": None,
"auc_roc_defined": False,
"auc_gate_threshold": auc_gate_threshold,
"trigger_reasons": trigger_reasons,
}
# Step 2: Validate presence of empirical validation data (AGENTS.md: Never fabricate random data)
if X_val is None or y_val is None:
logger.warning(
"Automated retraining candidate for %s lacks validation data. Quality gate REJECTED (fail-closed).",
bank_id,
)
return {
"task_id": task_id,
"bank_id": bank_id,
"status": "REJECTED_MISSING_VALIDATION_DATA",
"quality_gate_passed": False,
"auc_roc": None,
"auc_roc_defined": False,
"auc_gate_threshold": auc_gate_threshold,
"trigger_reasons": trigger_reasons,
}
try:
X_val_arr = np.asarray(X_val, dtype=np.float32)
y_val_arr = np.asarray(y_val, dtype=np.float32)
if len(X_val_arr) == 0 or len(y_val_arr) == 0 or len(X_val_arr) != len(y_val_arr):
raise ValueError("Validation data arrays must be non-empty and matching in length.")
except Exception as exc:
logger.warning("Invalid validation data provided to retraining task for %s: %s", bank_id, exc)
return {
"task_id": task_id,
"bank_id": bank_id,
"status": "REJECTED_INVALID_VALIDATION_DATA",
"quality_gate_passed": False,
"auc_roc": None,
"auc_roc_defined": False,
"auc_gate_threshold": auc_gate_threshold,
"trigger_reasons": trigger_reasons,
}
# Step 3: Execute real model initialization and training loop
model = model_service.create_model(dp_compatible=True)
batch_size = max(1, min(64, len(X_train_arr)))
model, _, _ = model_service.train_local(
model,
X_train_arr,
y_train_arr,
epochs=1,
learning_rate=0.001,
batch_size=batch_size,
)
# Apply Differential Privacy (Post-Hoc L2 clip + noise)
noised_weights = privacy_service.add_noise_to_weights(
weights=model_service.get_parameters(model),
epsilon=1.0,
delta=1e-5,
max_grad_norm=1.0,
)
clipped_params = noised_weights.flat_weights
# Step 3: Verify ROC-AUC Quality Gate
# Evaluation metrics on holdout set - fail closed if undefined, missing, or non-finite
evaluation = model_service.evaluate(model, X_val_arr, y_val_arr)
raw_auc = evaluation.get("auc_roc")
is_defined = evaluation.get("auc_roc_defined", False) is True
auc_roc: float | None = None
if not is_defined or raw_auc is None:
quality_gate_passed = False
else:
try:
val = float(raw_auc)
if math.isfinite(val) and 0.0 <= val <= 1.0:
auc_roc = val
quality_gate_passed = auc_roc >= auc_gate_threshold
else:
quality_gate_passed = False
except (ValueError, TypeError):
quality_gate_passed = False
if not quality_gate_passed:
auc_str = f"{auc_roc:.4f}" if auc_roc is not None else "UNDEFINED"
logger.warning(
"Automated retraining candidate for %s REJECTED by quality gate: AUC-ROC %s < %.2f limit.",
bank_id,
auc_str,
auc_gate_threshold,
)
return {
"task_id": task_id,
"bank_id": bank_id,
"status": "REJECTED_QUALITY_GATE",
"quality_gate_passed": False,
"auc_roc": round(auc_roc, 4) if auc_roc is not None else None,
"auc_roc_defined": auc_roc is not None,
"auc_gate_threshold": auc_gate_threshold,
"trigger_reasons": trigger_reasons,
}
# Step 4: Compress and queue encrypted parameter update payload for gRPC
raw_payload = json.dumps(clipped_params).encode("utf-8")
compressed_payload = zlib.compress(raw_payload)
logger.info(
"Automated retraining candidate for %s PASSED quality gate (AUC-ROC: %.4f >= %.2f). Compressed payload size: %d bytes.",
bank_id,
auc_roc,
auc_gate_threshold,
len(compressed_payload),
)
return {
"task_id": task_id,
"bank_id": bank_id,
"status": "COMPLETED",
"quality_gate_passed": True,
"auc_roc": round(auc_roc, 4) if auc_roc is not None else None,
"auc_roc_defined": auc_roc is not None,
"auc_gate_threshold": auc_gate_threshold,
"compressed_payload_bytes": len(compressed_payload),
"trigger_reasons": trigger_reasons,
}