""" SmartContainer Risk Engine — Flask REST API Run: python api.py Listens on: http://localhost:5000 """ import os import logging import numpy as np import pandas as pd import joblib from flask import Flask, request, jsonify from flask_cors import CORS logging.basicConfig( level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s' ) logger = logging.getLogger(__name__) MAX_BATCH_SIZE = 2000 # practical limit — full test set can be split into 2 chunks of ~4k # ── Load saved pipeline ─────────────────────────────────────────────────────── MODEL_PATH = os.path.join(os.path.dirname(__file__), 'risk_engine_pipeline.joblib') bundle = joblib.load(MODEL_PATH) lgb_model = bundle['lgb_model'] xgb_model = bundle['xgb_model'] LGB_WEIGHT = bundle['lgb_weight'] XGB_WEIGHT = bundle['xgb_weight'] scaler = bundle['scaler'] ord_enc = bundle['ord_enc'] freq_maps = bundle['freq_maps'] iso_forest = bundle['iso_forest'] FEATURE_COLS = bundle['feature_cols'] SCALE_COLS = bundle['scale_cols'] CONTINUOUS_FEATURES = bundle['continuous_features'] FLAG_FEATURES = bundle['flag_features'] FREQ_FEATURES = bundle['freq_features'] ORD_COLS_RAW = bundle['ord_cols_raw'] ORD_COLS_ENC = bundle['ord_cols_enc'] ANOMALY_FEATURES = bundle['anomaly_features'] CRITICAL_THRESHOLD = bundle['critical_threshold'] OPT_PROB_THRESHOLD = bundle['opt_prob_threshold'] app = Flask(__name__) CORS(app, origins=[ 'http://localhost:3000', 'https://*.vercel.app', 'https://*.hf.space', 'https://huggingface.co', ]) app.config['MAX_CONTENT_LENGTH'] = 5 * 1024 * 1024 # 5 MB max request body # ── Preprocessing helpers (mirrors notebook pipeline) ───────────────────────── def preprocess(df: pd.DataFrame) -> pd.DataFrame: df = df.copy() rename_map = { 'Declaration_Date (YYYY-MM-DD)': 'Declaration_Date', 'Trade_Regime (Import / Export / Transit)': 'Trade_Regime', } df.rename(columns=rename_map, inplace=True) df['Declaration_Date'] = pd.to_datetime(df['Declaration_Date'], errors='coerce') df['Declaration_Time'] = pd.to_datetime(df['Declaration_Time'], errors='coerce') df['Day_of_Week'] = df['Declaration_Date'].dt.dayofweek df['Month'] = df['Declaration_Date'].dt.month df['Hour'] = df['Declaration_Time'].dt.hour.fillna(0).astype(int) df['HS_Code'] = df['HS_Code'].astype(str) df['HS_Category'] = df['HS_Code'].str[:2] for col in ['Declared_Value', 'Declared_Weight', 'Measured_Weight', 'Dwell_Time_Hours']: df[col] = pd.to_numeric(df[col], errors='coerce').fillna(0.0) for col in ['Trade_Regime', 'Origin_Country', 'Destination_Country', 'Destination_Port', 'Shipping_Line', 'Importer_ID', 'Exporter_ID']: df[col] = df[col].fillna('UNKNOWN').astype(str) return df def engineer_features_inference(df: pd.DataFrame) -> pd.DataFrame: df = df.copy() df['Weight_Discrepancy'] = (df['Measured_Weight'] - df['Declared_Weight']).abs() df['Weight_Discrepancy_Ratio'] = df['Weight_Discrepancy'] / (df['Declared_Weight'].abs() + 1e-6) df['Weight_Ratio'] = df['Measured_Weight'] / (df['Declared_Weight'].abs() + 1e-6) df['Signed_Weight_Discrepancy'] = df['Measured_Weight'] - df['Declared_Weight'] df['Value_Per_KG'] = df['Declared_Value'] / (df['Declared_Weight'].abs() + 1e-6) df['Log_Value'] = np.log1p(df['Declared_Value']) df['Log_Value_Per_KG'] = np.log1p(df['Value_Per_KG']) df['Log_Dwell'] = np.log1p(df['Dwell_Time_Hours']) df['Discrepancy_x_Dwell'] = df['Weight_Discrepancy_Ratio'] * df['Log_Dwell'] df['Value_x_Discrepancy'] = df['Log_Value_Per_KG'] * df['Weight_Discrepancy_Ratio'] df['Is_Weekend'] = (df['Day_of_Week'] >= 5).astype(int) df['Is_Night_Hour'] = df['Hour'].apply(lambda h: 1 if h < 6 or h >= 21 else 0) df['Trade_Route'] = df['Origin_Country'].astype(str) + '_' + df['Destination_Port'].astype(str) df['Country_Pair'] = df['Origin_Country'].astype(str) + '_' + df['Destination_Country'].astype(str) df['Importer_Exporter_Pair'] = df['Importer_ID'].astype(str) + '_' + df['Exporter_ID'].astype(str) df['Importer_HS'] = df['Importer_ID'].astype(str) + '_' + df['HS_Category'].astype(str) FQ_COLS = ['Origin_Country', 'Destination_Country', 'Destination_Port', 'Importer_ID', 'Exporter_ID', 'Shipping_Line', 'HS_Category', 'Trade_Route', 'Trade_Regime', 'Country_Pair', 'Importer_Exporter_Pair', 'Importer_HS'] for col in FQ_COLS: df[col + '_Freq'] = df[col].astype(str).map(freq_maps.get(col, {})).fillna(0) df['Importer_Rare'] = (df['Importer_ID_Freq'] < 5).astype(int) df['Exporter_Rare'] = (df['Exporter_ID_Freq'] < 5).astype(int) df['Rare_Actor_Flag'] = ((df['Importer_ID_Freq'] < 5) | (df['Exporter_ID_Freq'] < 5)).astype(int) dwell_thresh = freq_maps['__dwell_mean'] + 2 * freq_maps['__dwell_std'] df['High_Dwell_Flag'] = (df['Dwell_Time_Hours'] > dwell_thresh).astype(int) df['Weight_Anomaly_Flag'] = (df['Weight_Discrepancy_Ratio'] > freq_maps['__wdr_p95']).astype(int) df['Value_Anomaly_Flag'] = (df['Value_Per_KG'] > freq_maps['__vwr_p95']).astype(int) df['Risk_Flag_Count'] = (df['High_Dwell_Flag'] + df['Weight_Anomaly_Flag'] + df['Value_Anomaly_Flag'] + df['Rare_Actor_Flag'] + df['Is_Night_Hour']) raw_anom = iso_forest.score_samples(df[ANOMALY_FEATURES]) s_min, s_max = raw_anom.min(), raw_anom.max() df['Anomaly_Score'] = 1.0 - (raw_anom - s_min) / (s_max - s_min + 1e-9) df['Is_Anomaly'] = (iso_forest.predict(df[ANOMALY_FEATURES]) == -1).astype(int) return df def compute_risk_score(df: pd.DataFrame, model_prob=None, model_weight=0.70) -> np.ndarray: wdr = df['Weight_Discrepancy_Ratio'].values vpr = df['Value_Per_KG'].values dwell = df['Dwell_Time_Hours'].values anom = df['Anomaly_Score'].values wdr_max = max(freq_maps['__wdr_p95'] * 3, 1e-6) vpr_max = max(freq_maps['__vwr_p95'] * 3, 1e-6) dwell_max = max(freq_maps['__dwell_mean'] + 3 * freq_maps['__dwell_std'], 1e-6) rule = (0.35 * np.clip(wdr / wdr_max, 0, 1) + 0.25 * np.clip(vpr / vpr_max, 0, 1) + 0.20 * np.clip(dwell / dwell_max, 0, 1) + 0.20 * anom) if model_prob is not None: combined = model_weight * model_prob + (1 - model_weight) * rule else: combined = rule return np.clip(combined * 100, 0, 100) def generate_explanation(row: dict) -> str: reasons = [] wdr = row.get('Weight_Discrepancy_Ratio', 0) if wdr > freq_maps['__wdr_p95']: reasons.append(f"high weight discrepancy of {row.get('Weight_Discrepancy', 0):.1f} kg (ratio={wdr:.2f})") vpr = row.get('Value_Per_KG', 0) if vpr > freq_maps['__vwr_p95']: reasons.append(f"abnormal value-per-kg ({vpr:.1f})") if row.get('High_Dwell_Flag', 0) == 1: reasons.append(f"excessive dwell time ({row.get('Dwell_Time_Hours', 0):.0f} hours)") if row.get('Importer_ID_Freq', 999) < 5: reasons.append('rare importer with few historical records') if row.get('Exporter_ID_Freq', 999) < 5: reasons.append('rare exporter with few historical records') if row.get('Trade_Route_Freq', 999) < 5: reasons.append('unusual or rarely observed trade route') if row.get('Is_Night_Hour', 0) == 1: reasons.append('declared during night-time hours') if row.get('Is_Anomaly', 0) == 1 and not reasons: reasons.append('shipment pattern flagged as anomalous by unsupervised detector') if not reasons: if row.get('Risk_Score', 0) > CRITICAL_THRESHOLD: return 'ML ensemble detected suspicious combined shipment pattern; no single dominant factor.' return 'No significant risk factors detected. Shipment appears compliant.' return 'Risk factors: ' + '; '.join(reasons) + '.' def run_pipeline(records: list) -> list: """Full inference pipeline on a list of raw container dicts.""" df_raw = pd.DataFrame(records) df_pre = preprocess(df_raw) df_fe = engineer_features_inference(df_pre) ord_df = pd.DataFrame( ord_enc.transform(df_fe[ORD_COLS_RAW].astype(str)), columns=ORD_COLS_ENC ) _base = CONTINUOUS_FEATURES + FLAG_FEATURES + FREQ_FEATURES X = pd.concat([df_fe[_base].reset_index(drop=True), ord_df], axis=1) X[SCALE_COLS] = scaler.transform(X[SCALE_COLS]) lgb_probs = lgb_model.predict_proba(X)[:, 1] xgb_probs = xgb_model.predict_proba(X)[:, 1] model_prob = LGB_WEIGHT * lgb_probs + XGB_WEIGHT * xgb_probs df_fe['Model_Prob'] = model_prob df_fe['Risk_Score'] = compute_risk_score(df_fe, model_prob=model_prob) df_fe['Risk_Level'] = np.where(df_fe['Risk_Score'] > CRITICAL_THRESHOLD, 'Critical', 'Low Risk') exp_cols = ['Weight_Discrepancy_Ratio', 'Weight_Discrepancy', 'Value_Per_KG', 'High_Dwell_Flag', 'Dwell_Time_Hours', 'Importer_ID_Freq', 'Exporter_ID_Freq', 'Trade_Route_Freq', 'Is_Anomaly', 'Is_Night_Hour', 'Risk_Score'] df_fe['Explanation_Summary'] = [generate_explanation(r) for r in df_fe[exp_cols].to_dict('records')] results = [] for i, row in df_fe.iterrows(): results.append({ 'Container_ID': str(df_raw.iloc[i].get('Container_ID', f'ROW_{i}')), 'Risk_Score': round(float(row['Risk_Score']), 2), 'Risk_Level': row['Risk_Level'], 'Model_Probability': round(float(row['Model_Prob']), 4), 'Explanation_Summary': row['Explanation_Summary'], 'Dwell_Time_Hours': round(float(row['Dwell_Time_Hours']), 1), 'Weight_Discrepancy_Ratio': round(float(row['Weight_Discrepancy_Ratio']), 4), 'Anomaly_Score': round(float(row['Anomaly_Score']), 4), 'Risk_Flag_Count': int(row['Risk_Flag_Count']), 'status': 'success', }) return results REQUIRED_FIELDS = [ 'Container_ID', 'Declaration_Date', 'Declaration_Time', 'HS_Code', 'Declared_Value', 'Declared_Weight', 'Measured_Weight', 'Dwell_Time_Hours', 'Trade_Regime', 'Origin_Country', 'Destination_Country', 'Destination_Port', 'Shipping_Line', 'Importer_ID', 'Exporter_ID', ] ALT_FIELD_NAMES = { 'Declaration_Date (YYYY-MM-DD)': 'Declaration_Date', 'Trade_Regime (Import / Export / Transit)': 'Trade_Regime', } def validate_record(record: dict, idx: int = 0) -> str | None: """Return error message if record is invalid, else None.""" normalized = {ALT_FIELD_NAMES.get(k, k): v for k, v in record.items()} missing = [f for f in REQUIRED_FIELDS if f not in normalized] if missing: return f'Record {idx}: missing required fields: {missing}' for num_field in ('Declared_Value', 'Declared_Weight', 'Measured_Weight', 'Dwell_Time_Hours'): val = normalized.get(num_field) try: float(val) except (TypeError, ValueError): return f'Record {idx}: {num_field} must be numeric, got {repr(val)}' return None # ── Routes ──────────────────────────────────────────────────────────────────── @app.route('/', methods=['GET']) def root(): html = f"""
AI/ML container shipment risk prediction — Nirma Hackathon 2026