HP25's picture
Feat: add HTML status page at root / with live model stats and endpoint docs
ed586de
Raw
History Blame Contribute Delete
18.5 kB
"""
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"""<!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> &nbsp;·&nbsp;
<a href="https://github.com/HARRY5D/Container_risk_engine" target="_blank">GitHub</a> &nbsp;·&nbsp;
<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'}
@app.errorhandler(413)
def request_too_large(e):
return jsonify({'status': 'error', 'message': 'Request body too large (max 5 MB)'}), 413
@app.errorhandler(405)
def method_not_allowed(e):
return jsonify({'status': 'error', 'message': 'Method not allowed'}), 405
@app.route('/api/health', methods=['GET'])
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),
})
@app.route('/api/predict', methods=['POST'])
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
@app.route('/api/predict/batch', methods=['POST'])
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)