Spaces:
Sleeping
Sleeping
| """ | |
| 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 ──────────────────────────────────────────────────────────────────── | |
| def root(): | |
| html = f"""<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"/> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"/> | |
| <title>SmartContainer Risk Engine — API</title> | |
| <style> | |
| *{{box-sizing:border-box;margin:0;padding:0}} | |
| body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0f172a;color:#e2e8f0;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:2rem}} | |
| .card{{background:#1e293b;border:1px solid #334155;border-radius:16px;padding:2.5rem;max-width:560px;width:100%;box-shadow:0 25px 50px rgba(0,0,0,.5)}} | |
| .badge{{display:inline-flex;align-items:center;gap:.4rem;background:#16a34a22;border:1px solid #16a34a55;color:#4ade80;padding:.25rem .75rem;border-radius:999px;font-size:.75rem;font-weight:600;margin-bottom:1.5rem}} | |
| .dot{{width:8px;height:8px;background:#4ade80;border-radius:50%;animation:pulse 2s infinite}} | |
| @keyframes pulse{{0%,100%{{opacity:1}}50%{{opacity:.4}}}} | |
| h1{{font-size:1.5rem;font-weight:700;color:#f8fafc;margin-bottom:.4rem}} | |
| .sub{{color:#94a3b8;font-size:.875rem;margin-bottom:2rem}} | |
| .stats{{display:grid;grid-template-columns:1fr 1fr;gap:.75rem;margin-bottom:2rem}} | |
| .stat{{background:#0f172a;border:1px solid #1e293b;border-radius:10px;padding:.875rem 1rem}} | |
| .stat-label{{font-size:.7rem;color:#64748b;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.25rem}} | |
| .stat-value{{font-size:1.1rem;font-weight:700;color:#f1f5f9}} | |
| h2{{font-size:.8rem;font-weight:600;color:#64748b;text-transform:uppercase;letter-spacing:.08em;margin-bottom:.75rem}} | |
| .endpoint{{display:flex;align-items:center;gap:.75rem;padding:.625rem .875rem;border-radius:8px;background:#0f172a;margin-bottom:.5rem;font-size:.82rem}} | |
| .method{{font-weight:700;padding:.15rem .5rem;border-radius:4px;font-size:.72rem;min-width:46px;text-align:center}} | |
| .get{{background:#1d4ed822;color:#60a5fa;border:1px solid #1d4ed855}} | |
| .post{{background:#16a34a22;color:#4ade80;border:1px solid #16a34a55}} | |
| .path{{font-family:'SF Mono',Consolas,monospace;color:#e2e8f0}} | |
| .desc{{color:#64748b;font-size:.75rem;margin-left:auto}} | |
| .footer{{margin-top:1.5rem;font-size:.75rem;color:#475569;text-align:center}} | |
| a{{color:#60a5fa;text-decoration:none}} | |
| a:hover{{text-decoration:underline}} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="card"> | |
| <div class="badge"><span class="dot"></span> API Online</div> | |
| <h1>SmartContainer Risk Engine</h1> | |
| <p class="sub">AI/ML container shipment risk prediction — Nirma Hackathon 2026</p> | |
| <div class="stats"> | |
| <div class="stat"><div class="stat-label">Model</div><div class="stat-value">LGB + XGB</div></div> | |
| <div class="stat"><div class="stat-label">Features</div><div class="stat-value">{len(FEATURE_COLS)}</div></div> | |
| <div class="stat"><div class="stat-label">Critical Threshold</div><div class="stat-value">{CRITICAL_THRESHOLD}</div></div> | |
| <div class="stat"><div class="stat-label">Prob Threshold</div><div class="stat-value">{round(OPT_PROB_THRESHOLD, 4)}</div></div> | |
| </div> | |
| <h2>Endpoints</h2> | |
| <div class="endpoint"><span class="method get">GET</span><span class="path">/api/health</span><span class="desc">Status + model metadata</span></div> | |
| <div class="endpoint"><span class="method post">POST</span><span class="path">/api/predict</span><span class="desc">Single container</span></div> | |
| <div class="endpoint"><span class="method post">POST</span><span class="path">/api/predict/batch</span><span class="desc">Up to 2000 containers</span></div> | |
| <div class="footer"> | |
| <a href="/api/health">JSON health</a> · | |
| <a href="https://github.com/HARRY5D/Container_risk_engine" target="_blank">GitHub</a> · | |
| <a href="https://huggingface.co/spaces/HP25/container-risk-engine" target="_blank">HF Space</a> | |
| </div> | |
| </div> | |
| </body> | |
| </html>""" | |
| return html, 200, {'Content-Type': 'text/html; charset=utf-8'} | |
| def request_too_large(e): | |
| return jsonify({'status': 'error', 'message': 'Request body too large (max 5 MB)'}), 413 | |
| def method_not_allowed(e): | |
| return jsonify({'status': 'error', 'message': 'Method not allowed'}), 405 | |
| def health(): | |
| return jsonify({ | |
| 'status': 'ok', | |
| 'model': 'SmartContainer Risk Engine', | |
| 'features': len(FEATURE_COLS), | |
| 'critical_threshold': CRITICAL_THRESHOLD, | |
| 'prob_threshold': round(OPT_PROB_THRESHOLD, 4), | |
| }) | |
| def predict_single(): | |
| """Single-container prediction.""" | |
| data = request.get_json(force=True, silent=True) | |
| if not data or not isinstance(data, dict): | |
| return jsonify({'status': 'error', 'message': 'Invalid or empty JSON body'}), 400 | |
| err = validate_record(data) | |
| if err: | |
| return jsonify({'status': 'error', 'message': err}), 422 | |
| try: | |
| result = run_pipeline([data]) | |
| logger.info('predict container=%s score=%.1f', data.get('Container_ID', '?'), result[0]['Risk_Score']) | |
| return jsonify(result[0]) | |
| except Exception as exc: | |
| logger.exception('predict error: %s', exc) | |
| return jsonify({'status': 'error', 'message': 'Internal prediction error'}), 500 | |
| def predict_batch(): | |
| """ | |
| Batch prediction. | |
| Body: JSON array of container objects OR { "containers": [...] } | |
| """ | |
| data = request.get_json(force=True, silent=True) | |
| if not data: | |
| return jsonify({'status': 'error', 'message': 'Invalid or empty JSON body'}), 400 | |
| records = data if isinstance(data, list) else data.get('containers', []) | |
| if not records: | |
| return jsonify({'status': 'error', 'message': 'No container records found'}), 400 | |
| if len(records) > MAX_BATCH_SIZE: | |
| return jsonify({'status': 'error', 'message': f'Batch too large: max {MAX_BATCH_SIZE} records'}), 422 | |
| for i, rec in enumerate(records): | |
| err = validate_record(rec, i) | |
| if err: | |
| return jsonify({'status': 'error', 'message': err}), 422 | |
| try: | |
| results = run_pipeline(records) | |
| logger.info('batch_predict count=%d critical=%d', len(results), | |
| sum(1 for r in results if r['Risk_Level'] == 'Critical')) | |
| return jsonify({'status': 'success', 'count': len(results), 'predictions': results}) | |
| except Exception as exc: | |
| logger.exception('batch predict error: %s', exc) | |
| return jsonify({'status': 'error', 'message': 'Internal prediction error'}), 500 | |
| if __name__ == '__main__': | |
| port = int(os.environ.get('PORT', 5000)) | |
| app.run(host='0.0.0.0', port=port, debug=False) | |