Spaces:
Sleeping
Sleeping
KaustavMP commited on
Commit ·
e5df031
0
Parent(s):
Initial FairSight project import
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .firebaserc +6 -0
- .gitignore +13 -0
- README.md +30 -0
- backend/.dockerignore +3 -0
- backend/Dockerfile +13 -0
- backend/config.py +35 -0
- backend/data/sample_dataset.csv +0 -0
- backend/main.py +154 -0
- backend/models/__init__.py +1 -0
- backend/models/enums.py +37 -0
- backend/models/schemas.py +205 -0
- backend/requirements.txt +8 -0
- backend/routers/__init__.py +1 -0
- backend/routers/alerts.py +36 -0
- backend/routers/fairness.py +89 -0
- backend/routers/overview.py +113 -0
- backend/routers/remediation.py +67 -0
- backend/routers/simulator.py +38 -0
- backend/services/__init__.py +1 -0
- backend/services/alert_service.py +153 -0
- backend/services/data_service.py +193 -0
- backend/services/fairness_engine.py +273 -0
- backend/services/proxy_detector.py +140 -0
- backend/services/remediation_service.py +302 -0
- backend/services/simulator_service.py +167 -0
- cloudbuild.yaml +34 -0
- firebase.json +27 -0
- firestore.indexes.json +32 -0
- firestore.rules +65 -0
- frontend/.env.example +8 -0
- frontend/index.html +13 -0
- frontend/package.json +24 -0
- frontend/src/App.jsx +48 -0
- frontend/src/components/AppShell.jsx +70 -0
- frontend/src/components/Charts.jsx +74 -0
- frontend/src/components/GeminiChat.jsx +61 -0
- frontend/src/contexts/AuthContext.jsx +45 -0
- frontend/src/hooks/useOrgCollection.js +39 -0
- frontend/src/lib/firebase.js +25 -0
- frontend/src/main.jsx +17 -0
- frontend/src/pages/AlertsPage.jsx +26 -0
- frontend/src/pages/DashboardPage.jsx +54 -0
- frontend/src/pages/LoginPage.jsx +51 -0
- frontend/src/pages/MetricsPage.jsx +54 -0
- frontend/src/pages/RemediationPage.jsx +21 -0
- frontend/src/pages/SimulatorPage.jsx +76 -0
- frontend/src/pages/UploadPage.jsx +63 -0
- frontend/src/styles/app.css +227 -0
- frontend/vite.config.js +10 -0
- functions/.env.example +5 -0
.firebaserc
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"projects": {
|
| 3 |
+
"default": "fairlens-494713"
|
| 4 |
+
}
|
| 5 |
+
}
|
| 6 |
+
|
.gitignore
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules/
|
| 2 |
+
.firebase/
|
| 3 |
+
.env
|
| 4 |
+
.env.local
|
| 5 |
+
.env.*.local
|
| 6 |
+
frontend/dist/
|
| 7 |
+
functions/node_modules/
|
| 8 |
+
functions/.env
|
| 9 |
+
functions/.secret.local
|
| 10 |
+
*.log
|
| 11 |
+
__pycache__/
|
| 12 |
+
*.pyc
|
| 13 |
+
|
README.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# FairSight
|
| 2 |
+
|
| 3 |
+
Firebase + React + Cloud Run scaffolding for the FairSight bias detection dashboard.
|
| 4 |
+
|
| 5 |
+
## Projects
|
| 6 |
+
|
| 7 |
+
- `frontend/`: React + Vite app
|
| 8 |
+
- `functions/`: Firebase Functions for upload pipeline, simulator wrapper, and Gemini integration
|
| 9 |
+
- `backend/`: Python fairness engine and Cloud Run service
|
| 10 |
+
|
| 11 |
+
## Setup
|
| 12 |
+
|
| 13 |
+
1. Install dependencies:
|
| 14 |
+
- `cd frontend && npm install`
|
| 15 |
+
- `cd ../functions && npm install`
|
| 16 |
+
2. Create `frontend/.env.local` from `frontend/.env.example`.
|
| 17 |
+
3. Create `functions/.env` from `functions/.env.example`.
|
| 18 |
+
4. Authenticate Firebase CLI and select project `fairlens-494713`.
|
| 19 |
+
5. Deploy backend to Cloud Run, then set `FAIRNESS_BACKEND_URL` in `functions/.env`.
|
| 20 |
+
|
| 21 |
+
## Firebase / GCP commands
|
| 22 |
+
|
| 23 |
+
```powershell
|
| 24 |
+
firebase use fairlens-494713
|
| 25 |
+
firebase deploy --only firestore,storage,functions,hosting
|
| 26 |
+
gcloud config set project fairlens-494713
|
| 27 |
+
gcloud builds submit backend --tag us-central1-docker.pkg.dev/fairlens-494713/fairlens/fairsight-backend
|
| 28 |
+
gcloud run deploy fairsight-backend --image us-central1-docker.pkg.dev/fairlens-494713/fairlens/fairsight-backend --platform managed --region us-central1 --allow-unauthenticated
|
| 29 |
+
```
|
| 30 |
+
|
backend/.dockerignore
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
|
backend/Dockerfile
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
COPY requirements.txt .
|
| 6 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 7 |
+
|
| 8 |
+
COPY . .
|
| 9 |
+
|
| 10 |
+
ENV PORT=8080
|
| 11 |
+
|
| 12 |
+
CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port ${PORT}"]
|
| 13 |
+
|
backend/config.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FairSight — Application Configuration & Constants
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
# ── Disparate Impact thresholds (EEOC 4/5ths rule) ───────────────────────
|
| 6 |
+
DI_CRITICAL_THRESHOLD = 0.80
|
| 7 |
+
DI_WARNING_THRESHOLD = 0.90
|
| 8 |
+
|
| 9 |
+
# ── Equal Opportunity Gap thresholds ──────────────────────────────────────
|
| 10 |
+
EO_CRITICAL_THRESHOLD = 0.15
|
| 11 |
+
EO_WARNING_THRESHOLD = 0.08
|
| 12 |
+
|
| 13 |
+
# ── Calibration Gap thresholds ────────────────────────────────────────────
|
| 14 |
+
CAL_CRITICAL_THRESHOLD = 0.12
|
| 15 |
+
CAL_WARNING_THRESHOLD = 0.06
|
| 16 |
+
|
| 17 |
+
# ── Proxy detection correlation threshold ─────────────────────────────────
|
| 18 |
+
PROXY_CORRELATION_THRESHOLD = 0.15
|
| 19 |
+
PROXY_HIGH_THRESHOLD = 0.35
|
| 20 |
+
|
| 21 |
+
# ── Protected attributes recognized by the system ────────────────────────
|
| 22 |
+
PROTECTED_ATTRIBUTES = ["gender", "race", "age", "disability"]
|
| 23 |
+
|
| 24 |
+
# ── Feature columns (non-protected) ──────────────────────────────────────
|
| 25 |
+
FEATURE_COLUMNS = [
|
| 26 |
+
"credit_score", "zip_code", "income", "education",
|
| 27 |
+
"employment_years", "debt_ratio", "loan_amount",
|
| 28 |
+
"school_name", "neighborhood"
|
| 29 |
+
]
|
| 30 |
+
|
| 31 |
+
# ── CORS Origins ──────────────────────────────────────────────────────────
|
| 32 |
+
ALLOWED_ORIGINS = ["*"]
|
| 33 |
+
|
| 34 |
+
# ── Dataset path ──────────────────────────────────────────────────────────
|
| 35 |
+
SAMPLE_DATASET_PATH = "data/sample_dataset.csv"
|
backend/data/sample_dataset.csv
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
backend/main.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FairSight — Bias Detection Suite
|
| 3 |
+
═══════════════════════════════════════════════════════════════════════════
|
| 4 |
+
Inspect datasets and AI models for hidden discrimination before deployment.
|
| 5 |
+
|
| 6 |
+
Main FastAPI application entry point.
|
| 7 |
+
"""
|
| 8 |
+
from fastapi import FastAPI, UploadFile, File, HTTPException
|
| 9 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 10 |
+
from contextlib import asynccontextmanager
|
| 11 |
+
import pandas as pd
|
| 12 |
+
import io
|
| 13 |
+
|
| 14 |
+
from config import ALLOWED_ORIGINS
|
| 15 |
+
from services.data_service import get_dataset, reload_dataset, get_dataset_info
|
| 16 |
+
from models.schemas import DatasetInfo
|
| 17 |
+
|
| 18 |
+
# Import routers
|
| 19 |
+
from routers.overview import router as overview_router
|
| 20 |
+
from routers.fairness import router as fairness_router
|
| 21 |
+
from routers.alerts import router as alerts_router
|
| 22 |
+
from routers.remediation import router as remediation_router
|
| 23 |
+
from routers.simulator import router as simulator_router
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@asynccontextmanager
|
| 27 |
+
async def lifespan(app: FastAPI):
|
| 28 |
+
"""Initialize the dataset on startup."""
|
| 29 |
+
print("⚡ FairSight — Initializing dataset...")
|
| 30 |
+
df = get_dataset()
|
| 31 |
+
print(f"✅ Dataset loaded: {len(df)} rows, {len(df.columns)} columns")
|
| 32 |
+
info = get_dataset_info()
|
| 33 |
+
print(f" Protected attributes: {info['protected_attributes_found']}")
|
| 34 |
+
print(f" Decision column: {info['decision_column']}")
|
| 35 |
+
print("🚀 FairSight backend is ready!")
|
| 36 |
+
yield
|
| 37 |
+
print("👋 FairSight shutting down.")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
app = FastAPI(
|
| 41 |
+
title="FairSight — Bias Detection Suite",
|
| 42 |
+
description=(
|
| 43 |
+
"Inspect datasets and AI models for hidden discrimination before deployment. "
|
| 44 |
+
"Provides disparate impact analysis, proxy variable detection, "
|
| 45 |
+
"remediation recommendations, and what-if simulation."
|
| 46 |
+
),
|
| 47 |
+
version="1.0.0",
|
| 48 |
+
lifespan=lifespan,
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
# ── CORS ──────────────────────────────────────────────────────────────────
|
| 52 |
+
app.add_middleware(
|
| 53 |
+
CORSMiddleware,
|
| 54 |
+
allow_origins=ALLOWED_ORIGINS,
|
| 55 |
+
allow_credentials=True,
|
| 56 |
+
allow_methods=["*"],
|
| 57 |
+
allow_headers=["*"],
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
# ── Mount routers ─────────────────────────────────────────────────────────
|
| 61 |
+
app.include_router(overview_router)
|
| 62 |
+
app.include_router(fairness_router)
|
| 63 |
+
app.include_router(alerts_router)
|
| 64 |
+
app.include_router(remediation_router)
|
| 65 |
+
app.include_router(simulator_router)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# ── Root endpoint ─────────────────────────────────────────────────────────
|
| 69 |
+
@app.get("/", tags=["Health"])
|
| 70 |
+
async def root():
|
| 71 |
+
return {
|
| 72 |
+
"name": "FairSight — Bias Detection Suite",
|
| 73 |
+
"version": "1.0.0",
|
| 74 |
+
"status": "running",
|
| 75 |
+
"endpoints": {
|
| 76 |
+
"overview": "/api/overview",
|
| 77 |
+
"fairness_metrics": "/api/fairness-metrics",
|
| 78 |
+
"alerts": "/api/alerts",
|
| 79 |
+
"remediation": "/api/remediation",
|
| 80 |
+
"simulator": "/api/simulator",
|
| 81 |
+
"dataset_info": "/api/dataset/info",
|
| 82 |
+
"upload_dataset": "/api/dataset/upload",
|
| 83 |
+
"docs": "/docs",
|
| 84 |
+
},
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# ── Dataset management endpoints ─────────────────────────────────────────
|
| 89 |
+
@app.get("/api/dataset/info", response_model=DatasetInfo, tags=["Dataset"])
|
| 90 |
+
async def dataset_info():
|
| 91 |
+
"""Return metadata about the currently loaded dataset."""
|
| 92 |
+
info = get_dataset_info()
|
| 93 |
+
return DatasetInfo(**info)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
@app.post("/api/dataset/upload", response_model=DatasetInfo, tags=["Dataset"])
|
| 97 |
+
async def upload_dataset(file: UploadFile = File(...)):
|
| 98 |
+
"""
|
| 99 |
+
Upload a custom CSV dataset.
|
| 100 |
+
|
| 101 |
+
Required columns:
|
| 102 |
+
- At least one protected attribute (gender, race, age, disability)
|
| 103 |
+
- A 'decision' column (0/1)
|
| 104 |
+
- A 'qualified' column (0/1) for EO and calibration metrics
|
| 105 |
+
- A 'predicted_prob' column (0–1) for calibration and scatter plots
|
| 106 |
+
"""
|
| 107 |
+
if not file.filename.endswith(".csv"):
|
| 108 |
+
raise HTTPException(status_code=400, detail="Only CSV files are supported.")
|
| 109 |
+
|
| 110 |
+
try:
|
| 111 |
+
contents = await file.read()
|
| 112 |
+
df = pd.read_csv(io.BytesIO(contents))
|
| 113 |
+
except Exception as e:
|
| 114 |
+
raise HTTPException(status_code=400, detail=f"Failed to parse CSV: {str(e)}")
|
| 115 |
+
|
| 116 |
+
# Validate required columns
|
| 117 |
+
required = ["decision"]
|
| 118 |
+
missing = [col for col in required if col not in df.columns]
|
| 119 |
+
if missing:
|
| 120 |
+
raise HTTPException(
|
| 121 |
+
status_code=400,
|
| 122 |
+
detail=f"Missing required columns: {missing}. Must have at least 'decision'."
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
protected_found = [c for c in ["gender", "race", "age", "disability"] if c in df.columns]
|
| 126 |
+
if not protected_found:
|
| 127 |
+
raise HTTPException(
|
| 128 |
+
status_code=400,
|
| 129 |
+
detail="No protected attributes found. Include at least one of: gender, race, age, disability."
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
# Add missing optional columns with defaults
|
| 133 |
+
if "qualified" not in df.columns:
|
| 134 |
+
df["qualified"] = df["decision"] # assume decision = qualification if not provided
|
| 135 |
+
if "predicted_prob" not in df.columns:
|
| 136 |
+
df["predicted_prob"] = df["decision"].astype(float)
|
| 137 |
+
|
| 138 |
+
reload_dataset(df)
|
| 139 |
+
info = get_dataset_info()
|
| 140 |
+
return DatasetInfo(**info)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
@app.post("/api/dataset/reset", tags=["Dataset"])
|
| 144 |
+
async def reset_dataset():
|
| 145 |
+
"""Reset to the built-in sample dataset."""
|
| 146 |
+
reload_dataset(None)
|
| 147 |
+
info = get_dataset_info()
|
| 148 |
+
return {"message": "Dataset reset to sample data.", "info": info}
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
# ── Run with uvicorn ──────────────────────────────────────────────────────
|
| 152 |
+
if __name__ == "__main__":
|
| 153 |
+
import uvicorn
|
| 154 |
+
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
backend/models/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# FairSight Models Package
|
backend/models/enums.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FairSight — Enumerations for protected attributes, severity levels, and metric types.
|
| 3 |
+
"""
|
| 4 |
+
from enum import Enum
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class ProtectedAttribute(str, Enum):
|
| 8 |
+
GENDER = "gender"
|
| 9 |
+
RACE = "race"
|
| 10 |
+
AGE = "age"
|
| 11 |
+
DISABILITY = "disability"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class Severity(str, Enum):
|
| 15 |
+
CRITICAL = "critical"
|
| 16 |
+
WARNING = "warning"
|
| 17 |
+
INFO = "info"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class AlertType(str, Enum):
|
| 21 |
+
DIRECT_DISCRIMINATION = "direct_discrimination"
|
| 22 |
+
PROXY_VARIABLE = "proxy_variable"
|
| 23 |
+
SAMPLE_SIZE = "sample_size"
|
| 24 |
+
CALIBRATION = "calibration"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class RemediationType(str, Enum):
|
| 28 |
+
REMOVE_PROXY = "remove_proxy"
|
| 29 |
+
REWEIGHT_DATA = "reweight_data"
|
| 30 |
+
ADJUST_THRESHOLD = "adjust_threshold"
|
| 31 |
+
POST_PROCESS = "post_process"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class MetricStatus(str, Enum):
|
| 35 |
+
PASSING = "passing"
|
| 36 |
+
NEEDS_REVIEW = "needs_review"
|
| 37 |
+
CRITICAL = "critical"
|
backend/models/schemas.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FairSight — Pydantic schemas for all API request and response models.
|
| 3 |
+
"""
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
from typing import Optional
|
| 6 |
+
from pydantic import BaseModel, Field
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 10 |
+
# Overview endpoint schemas
|
| 11 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 12 |
+
|
| 13 |
+
class ApprovalRate(BaseModel):
|
| 14 |
+
group: str
|
| 15 |
+
rate: float = Field(..., ge=0, le=1, description="Approval rate 0–1")
|
| 16 |
+
count: int = Field(..., description="Total individuals in group")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class FairnessScore(BaseModel):
|
| 20 |
+
metric: str
|
| 21 |
+
score: float = Field(..., ge=0, le=100)
|
| 22 |
+
status: str # "passing", "needs_review", "critical"
|
| 23 |
+
description: str
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class HeatmapCell(BaseModel):
|
| 27 |
+
feature: str
|
| 28 |
+
protected_attribute: str
|
| 29 |
+
correlation: float = Field(..., ge=0, le=1)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class OverviewResponse(BaseModel):
|
| 33 |
+
overall_fairness_score: float = Field(..., ge=0, le=100)
|
| 34 |
+
disparate_impact_ratio: float
|
| 35 |
+
groups_analyzed: int
|
| 36 |
+
group_names: list[str]
|
| 37 |
+
flagged_features: int
|
| 38 |
+
proxy_variables_found: int
|
| 39 |
+
approval_rates: list[ApprovalRate]
|
| 40 |
+
fairness_scores: list[FairnessScore]
|
| 41 |
+
heatmap: list[HeatmapCell]
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 45 |
+
# Fairness Metrics endpoint schemas
|
| 46 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 47 |
+
|
| 48 |
+
class GroupMetric(BaseModel):
|
| 49 |
+
group: str
|
| 50 |
+
value: float
|
| 51 |
+
sample_size: int
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class ScatterPoint(BaseModel):
|
| 55 |
+
actual: float
|
| 56 |
+
predicted: float
|
| 57 |
+
group: str
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class FairnessMetricsResponse(BaseModel):
|
| 61 |
+
protected_attribute: str
|
| 62 |
+
disparate_impact: float
|
| 63 |
+
disparate_impact_status: str
|
| 64 |
+
equal_opportunity_gap: float
|
| 65 |
+
equal_opportunity_status: str
|
| 66 |
+
calibration_gap: float
|
| 67 |
+
calibration_status: str
|
| 68 |
+
group_details: list[GroupMetric]
|
| 69 |
+
scatter_data: list[ScatterPoint]
|
| 70 |
+
reference_group: str
|
| 71 |
+
description: str
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 75 |
+
# Alerts endpoint schemas
|
| 76 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 77 |
+
|
| 78 |
+
class Alert(BaseModel):
|
| 79 |
+
id: str
|
| 80 |
+
type: str # "direct_discrimination" | "proxy_variable"
|
| 81 |
+
severity: str # "critical" | "warning" | "info"
|
| 82 |
+
title: str
|
| 83 |
+
description: str
|
| 84 |
+
affected_groups: list[str]
|
| 85 |
+
affected_features: list[str]
|
| 86 |
+
metric_value: Optional[float] = None
|
| 87 |
+
threshold: Optional[float] = None
|
| 88 |
+
recommendation: str
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class AlertsResponse(BaseModel):
|
| 92 |
+
total_alerts: int
|
| 93 |
+
critical_count: int
|
| 94 |
+
warning_count: int
|
| 95 |
+
info_count: int
|
| 96 |
+
alerts: list[Alert]
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 100 |
+
# Remediation endpoint schemas
|
| 101 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 102 |
+
|
| 103 |
+
class Intervention(BaseModel):
|
| 104 |
+
id: str
|
| 105 |
+
type: str # "remove_proxy" | "reweight_data" | "adjust_threshold" | "post_process"
|
| 106 |
+
title: str
|
| 107 |
+
description: str
|
| 108 |
+
priority: int = Field(..., ge=1, le=5)
|
| 109 |
+
expected_di_improvement: float
|
| 110 |
+
expected_eo_improvement: float
|
| 111 |
+
expected_cal_improvement: float
|
| 112 |
+
risk_level: str
|
| 113 |
+
implementation_effort: str # "low" | "medium" | "high"
|
| 114 |
+
affected_features: list[str]
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
class FairnessConstraint(BaseModel):
|
| 118 |
+
name: str
|
| 119 |
+
current_value: float
|
| 120 |
+
min_value: float
|
| 121 |
+
max_value: float
|
| 122 |
+
step: float
|
| 123 |
+
description: str
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
class RemediationResponse(BaseModel):
|
| 127 |
+
current_disparate_impact: float
|
| 128 |
+
target_disparate_impact: float
|
| 129 |
+
interventions: list[Intervention]
|
| 130 |
+
fairness_constraints: list[FairnessConstraint]
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
class ApplyRemediationRequest(BaseModel):
|
| 134 |
+
intervention_ids: list[str]
|
| 135 |
+
fairness_constraints: dict[str, float] = Field(
|
| 136 |
+
default_factory=dict,
|
| 137 |
+
description="Map of constraint name → desired value"
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
class ApplyRemediationResponse(BaseModel):
|
| 142 |
+
original_disparate_impact: float
|
| 143 |
+
new_disparate_impact: float
|
| 144 |
+
original_eo_gap: float
|
| 145 |
+
new_eo_gap: float
|
| 146 |
+
original_cal_gap: float
|
| 147 |
+
new_cal_gap: float
|
| 148 |
+
approval_rates_before: list[ApprovalRate]
|
| 149 |
+
approval_rates_after: list[ApprovalRate]
|
| 150 |
+
interventions_applied: list[str]
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 154 |
+
# Simulator endpoint schemas
|
| 155 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 156 |
+
|
| 157 |
+
class SimulatorRequest(BaseModel):
|
| 158 |
+
threshold_adjustment: float = Field(
|
| 159 |
+
default=0.0, ge=-0.5, le=0.5,
|
| 160 |
+
description="Shift the decision threshold (negative = more approvals)"
|
| 161 |
+
)
|
| 162 |
+
reweighting_strength: float = Field(
|
| 163 |
+
default=0.0, ge=0.0, le=1.0,
|
| 164 |
+
description="How aggressively to reweight underrepresented groups (0=none, 1=full)"
|
| 165 |
+
)
|
| 166 |
+
calibration_strength: float = Field(
|
| 167 |
+
default=0.0, ge=0.0, le=1.0,
|
| 168 |
+
description="How much to apply Platt-style calibration (0=none, 1=full)"
|
| 169 |
+
)
|
| 170 |
+
remove_features: list[str] = Field(
|
| 171 |
+
default_factory=list,
|
| 172 |
+
description="Feature names to remove from the model"
|
| 173 |
+
)
|
| 174 |
+
protected_attribute: str = Field(
|
| 175 |
+
default="race",
|
| 176 |
+
description="Protected attribute to evaluate"
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
class SimulatorResult(BaseModel):
|
| 181 |
+
original_approval_rates: list[ApprovalRate]
|
| 182 |
+
simulated_approval_rates: list[ApprovalRate]
|
| 183 |
+
original_disparate_impact: float
|
| 184 |
+
simulated_disparate_impact: float
|
| 185 |
+
original_eo_gap: float
|
| 186 |
+
simulated_eo_gap: float
|
| 187 |
+
original_cal_gap: float
|
| 188 |
+
simulated_cal_gap: float
|
| 189 |
+
overall_approval_change: float
|
| 190 |
+
accuracy_impact: float = Field(
|
| 191 |
+
..., description="Estimated accuracy change (negative = accuracy drop)"
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 196 |
+
# Dataset upload schema
|
| 197 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 198 |
+
|
| 199 |
+
class DatasetInfo(BaseModel):
|
| 200 |
+
rows: int
|
| 201 |
+
columns: int
|
| 202 |
+
column_names: list[str]
|
| 203 |
+
protected_attributes_found: list[str]
|
| 204 |
+
decision_column: str
|
| 205 |
+
qualification_column: str
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.111.0
|
| 2 |
+
uvicorn[standard]==0.30.1
|
| 3 |
+
pandas==2.2.2
|
| 4 |
+
numpy==1.26.4
|
| 5 |
+
scikit-learn==1.5.0
|
| 6 |
+
scipy==1.13.1
|
| 7 |
+
pydantic==2.7.4
|
| 8 |
+
python-multipart==0.0.9
|
backend/routers/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# FairSight Routers Package
|
backend/routers/alerts.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FairSight — Alerts Router
|
| 3 |
+
GET /api/alerts → priority-ordered fairness issues
|
| 4 |
+
"""
|
| 5 |
+
from fastapi import APIRouter
|
| 6 |
+
|
| 7 |
+
from services.data_service import get_dataset
|
| 8 |
+
from services.alert_service import generate_alerts
|
| 9 |
+
from models.schemas import AlertsResponse
|
| 10 |
+
|
| 11 |
+
router = APIRouter(prefix="/api", tags=["Alerts"])
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@router.get("/alerts", response_model=AlertsResponse)
|
| 15 |
+
async def get_alerts():
|
| 16 |
+
"""
|
| 17 |
+
Surface critical fairness issues in priority order.
|
| 18 |
+
|
| 19 |
+
Flags both direct discrimination (disparate impact, equal opportunity
|
| 20 |
+
violations) and proxy variables (features that appear neutral but
|
| 21 |
+
encode protected characteristics).
|
| 22 |
+
"""
|
| 23 |
+
df = get_dataset()
|
| 24 |
+
alerts = generate_alerts(df)
|
| 25 |
+
|
| 26 |
+
critical_count = sum(1 for a in alerts if a["severity"] == "critical")
|
| 27 |
+
warning_count = sum(1 for a in alerts if a["severity"] == "warning")
|
| 28 |
+
info_count = sum(1 for a in alerts if a["severity"] == "info")
|
| 29 |
+
|
| 30 |
+
return AlertsResponse(
|
| 31 |
+
total_alerts=len(alerts),
|
| 32 |
+
critical_count=critical_count,
|
| 33 |
+
warning_count=warning_count,
|
| 34 |
+
info_count=info_count,
|
| 35 |
+
alerts=alerts,
|
| 36 |
+
)
|
backend/routers/fairness.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FairSight — Fairness Metrics Router
|
| 3 |
+
GET /api/fairness-metrics?protected_attr=race → detailed fairness breakdown
|
| 4 |
+
"""
|
| 5 |
+
from fastapi import APIRouter, Query
|
| 6 |
+
|
| 7 |
+
from services.data_service import get_dataset
|
| 8 |
+
from services.fairness_engine import (
|
| 9 |
+
compute_disparate_impact,
|
| 10 |
+
compute_equal_opportunity_gap,
|
| 11 |
+
compute_calibration_gap,
|
| 12 |
+
compute_scatter_data,
|
| 13 |
+
)
|
| 14 |
+
from models.schemas import FairnessMetricsResponse
|
| 15 |
+
|
| 16 |
+
router = APIRouter(prefix="/api", tags=["Fairness Metrics"])
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@router.get("/fairness-metrics", response_model=FairnessMetricsResponse)
|
| 20 |
+
async def get_fairness_metrics(
|
| 21 |
+
protected_attr: str = Query(
|
| 22 |
+
default="race",
|
| 23 |
+
description="Protected attribute to evaluate: gender, race, age, disability"
|
| 24 |
+
),
|
| 25 |
+
decision_type: str = Query(
|
| 26 |
+
default="decision",
|
| 27 |
+
description="Column name for the decision outcome"
|
| 28 |
+
),
|
| 29 |
+
):
|
| 30 |
+
"""
|
| 31 |
+
Compute and return detailed fairness metrics for a specific protected attribute.
|
| 32 |
+
|
| 33 |
+
Returns:
|
| 34 |
+
- Disparate impact ratio with 4/5ths rule evaluation
|
| 35 |
+
- Equal opportunity gap (TPR parity)
|
| 36 |
+
- Calibration gap (prediction-reality alignment)
|
| 37 |
+
- Scatter plot data (predicted vs actual qualifications)
|
| 38 |
+
- Per-group breakdown
|
| 39 |
+
"""
|
| 40 |
+
df = get_dataset()
|
| 41 |
+
|
| 42 |
+
if protected_attr not in df.columns:
|
| 43 |
+
protected_attr = "race"
|
| 44 |
+
|
| 45 |
+
if decision_type not in df.columns:
|
| 46 |
+
decision_type = "decision"
|
| 47 |
+
|
| 48 |
+
# ── Compute all three metrics ─────────────────────────────────────────
|
| 49 |
+
di_result = compute_disparate_impact(df, protected_attr, decision_type)
|
| 50 |
+
eo_result = compute_equal_opportunity_gap(df, protected_attr, decision_type)
|
| 51 |
+
cal_result = compute_calibration_gap(df, protected_attr)
|
| 52 |
+
scatter = compute_scatter_data(df, protected_attr)
|
| 53 |
+
|
| 54 |
+
# ── Per-group details ─────────────────────────────────────────────────
|
| 55 |
+
group_details = []
|
| 56 |
+
for group in sorted(df[protected_attr].unique()):
|
| 57 |
+
mask = df[protected_attr] == group
|
| 58 |
+
group_details.append({
|
| 59 |
+
"group": str(group),
|
| 60 |
+
"value": round(float(df.loc[mask, decision_type].mean()), 3),
|
| 61 |
+
"sample_size": int(mask.sum()),
|
| 62 |
+
})
|
| 63 |
+
|
| 64 |
+
# ── Description ───────────────────────────────────────────────────────
|
| 65 |
+
description = (
|
| 66 |
+
f"Fairness analysis for '{protected_attr}' across {len(group_details)} groups. "
|
| 67 |
+
f"Reference group (highest approval): {di_result.get('reference_group', 'N/A')}. "
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
if di_result["ratio"] < 0.80:
|
| 71 |
+
description += "⚠️ The 4/5ths rule is violated — this may indicate illegal discrimination. "
|
| 72 |
+
if eo_result["gap"] > 0.10:
|
| 73 |
+
description += "Qualified individuals face significantly unequal treatment across groups. "
|
| 74 |
+
if cal_result["gap"] > 0.08:
|
| 75 |
+
description += "Model predictions are not equally calibrated across groups."
|
| 76 |
+
|
| 77 |
+
return FairnessMetricsResponse(
|
| 78 |
+
protected_attribute=protected_attr,
|
| 79 |
+
disparate_impact=di_result["ratio"],
|
| 80 |
+
disparate_impact_status=di_result["status"].value,
|
| 81 |
+
equal_opportunity_gap=eo_result["gap"],
|
| 82 |
+
equal_opportunity_status=eo_result["status"].value,
|
| 83 |
+
calibration_gap=cal_result["gap"],
|
| 84 |
+
calibration_status=cal_result["status"].value,
|
| 85 |
+
group_details=group_details,
|
| 86 |
+
scatter_data=scatter,
|
| 87 |
+
reference_group=di_result.get("reference_group", ""),
|
| 88 |
+
description=description,
|
| 89 |
+
)
|
backend/routers/overview.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FairSight — Overview Router
|
| 3 |
+
GET /api/overview → at-a-glance fairness scorecard
|
| 4 |
+
"""
|
| 5 |
+
from fastapi import APIRouter
|
| 6 |
+
|
| 7 |
+
from services.data_service import get_dataset
|
| 8 |
+
from services.fairness_engine import (
|
| 9 |
+
compute_disparate_impact,
|
| 10 |
+
compute_equal_opportunity_gap,
|
| 11 |
+
compute_calibration_gap,
|
| 12 |
+
compute_overall_fairness_score,
|
| 13 |
+
get_metric_status_label,
|
| 14 |
+
)
|
| 15 |
+
from services.proxy_detector import get_heatmap_data, detect_proxy_variables
|
| 16 |
+
from config import PROTECTED_ATTRIBUTES, FEATURE_COLUMNS
|
| 17 |
+
from models.schemas import OverviewResponse
|
| 18 |
+
|
| 19 |
+
router = APIRouter(prefix="/api", tags=["Overview"])
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@router.get("/overview", response_model=OverviewResponse)
|
| 23 |
+
async def get_overview():
|
| 24 |
+
"""
|
| 25 |
+
Return the at-a-glance fairness scorecard:
|
| 26 |
+
- Overall fairness score (0–100)
|
| 27 |
+
- Disparate impact ratio
|
| 28 |
+
- Groups analyzed
|
| 29 |
+
- Flagged features / proxy variables
|
| 30 |
+
- Approval rates by demographic group
|
| 31 |
+
- Model fairness scores (DI, EO, Calibration)
|
| 32 |
+
- Dataset quality heatmap
|
| 33 |
+
"""
|
| 34 |
+
df = get_dataset()
|
| 35 |
+
|
| 36 |
+
available_attrs = [a for a in PROTECTED_ATTRIBUTES if a in df.columns]
|
| 37 |
+
available_features = [f for f in FEATURE_COLUMNS if f in df.columns]
|
| 38 |
+
|
| 39 |
+
# ── Collect all unique groups ─────────────────────────────────────────
|
| 40 |
+
all_groups = set()
|
| 41 |
+
for attr in available_attrs:
|
| 42 |
+
all_groups.update(str(v) for v in df[attr].unique().tolist())
|
| 43 |
+
|
| 44 |
+
# ── Primary metric: race (most common fairness evaluation) ────────────
|
| 45 |
+
primary_attr = "race" if "race" in df.columns else available_attrs[0]
|
| 46 |
+
|
| 47 |
+
di_result = compute_disparate_impact(df, primary_attr)
|
| 48 |
+
eo_result = compute_equal_opportunity_gap(df, primary_attr)
|
| 49 |
+
cal_result = compute_calibration_gap(df, primary_attr)
|
| 50 |
+
|
| 51 |
+
overall_score = compute_overall_fairness_score(
|
| 52 |
+
di_result["ratio"], eo_result["gap"], cal_result["gap"]
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
# ── Proxy detection ───────────────────────────────────────────────────
|
| 56 |
+
proxies = detect_proxy_variables(df, available_features, available_attrs)
|
| 57 |
+
flagged_features = len(set(p["feature"] for p in proxies))
|
| 58 |
+
|
| 59 |
+
# ── Approval rates by demographic group ───────────────────────────────
|
| 60 |
+
approval_rates = []
|
| 61 |
+
for attr in available_attrs:
|
| 62 |
+
for group in sorted(df[attr].unique()):
|
| 63 |
+
mask = df[attr] == group
|
| 64 |
+
rate = df.loc[mask, "decision"].mean()
|
| 65 |
+
count = int(mask.sum())
|
| 66 |
+
# Avoid duplicates if same group name across attributes
|
| 67 |
+
if not any(ar["group"] == str(group) for ar in approval_rates):
|
| 68 |
+
approval_rates.append({
|
| 69 |
+
"group": str(group),
|
| 70 |
+
"rate": round(float(rate), 3),
|
| 71 |
+
"count": count,
|
| 72 |
+
})
|
| 73 |
+
|
| 74 |
+
# ── Fairness scores ───────────────────────────────────────────────────
|
| 75 |
+
di_score_value = round(min(100, max(0, (di_result["ratio"] / 1.0) * 100)), 1)
|
| 76 |
+
eo_score_value = round(min(100, max(0, (1 - eo_result["gap"] / 0.30) * 100)), 1)
|
| 77 |
+
cal_score_value = round(min(100, max(0, (1 - cal_result["gap"] / 0.30) * 100)), 1)
|
| 78 |
+
|
| 79 |
+
fairness_scores = [
|
| 80 |
+
{
|
| 81 |
+
"metric": "Disparate impact",
|
| 82 |
+
"score": di_score_value,
|
| 83 |
+
"status": di_result["status"].value,
|
| 84 |
+
"description": f"DI ratio of {di_result['ratio']:.2f} — {'below' if di_result['ratio'] < 0.80 else 'at or above'} the 4/5ths threshold",
|
| 85 |
+
},
|
| 86 |
+
{
|
| 87 |
+
"metric": "Equal opportunity",
|
| 88 |
+
"score": eo_score_value,
|
| 89 |
+
"status": eo_result["status"].value,
|
| 90 |
+
"description": f"EO gap of {eo_result['gap']:.1%} between best and worst performing groups",
|
| 91 |
+
},
|
| 92 |
+
{
|
| 93 |
+
"metric": "Calibration",
|
| 94 |
+
"score": cal_score_value,
|
| 95 |
+
"status": cal_result["status"].value,
|
| 96 |
+
"description": f"Calibration gap of {cal_result['gap']:.1%} across groups",
|
| 97 |
+
},
|
| 98 |
+
]
|
| 99 |
+
|
| 100 |
+
# ── Heatmap data ──────────────────────────────────────────────────────
|
| 101 |
+
heatmap = get_heatmap_data(df, available_features, available_attrs)
|
| 102 |
+
|
| 103 |
+
return OverviewResponse(
|
| 104 |
+
overall_fairness_score=overall_score,
|
| 105 |
+
disparate_impact_ratio=di_result["ratio"],
|
| 106 |
+
groups_analyzed=len(all_groups),
|
| 107 |
+
group_names=sorted(all_groups),
|
| 108 |
+
flagged_features=flagged_features,
|
| 109 |
+
proxy_variables_found=len(proxies),
|
| 110 |
+
approval_rates=approval_rates,
|
| 111 |
+
fairness_scores=fairness_scores,
|
| 112 |
+
heatmap=heatmap,
|
| 113 |
+
)
|
backend/routers/remediation.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FairSight — Remediation Router
|
| 3 |
+
GET /api/remediation → get prioritized interventions
|
| 4 |
+
POST /api/remediation/apply → apply selected interventions and see impact
|
| 5 |
+
"""
|
| 6 |
+
from fastapi import APIRouter, Query
|
| 7 |
+
|
| 8 |
+
from services.data_service import get_dataset
|
| 9 |
+
from services.fairness_engine import compute_disparate_impact
|
| 10 |
+
from services.remediation_service import (
|
| 11 |
+
generate_interventions,
|
| 12 |
+
get_fairness_constraints,
|
| 13 |
+
apply_remediation,
|
| 14 |
+
)
|
| 15 |
+
from models.schemas import (
|
| 16 |
+
RemediationResponse,
|
| 17 |
+
ApplyRemediationRequest,
|
| 18 |
+
ApplyRemediationResponse,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
router = APIRouter(prefix="/api", tags=["Remediation"])
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@router.get("/remediation", response_model=RemediationResponse)
|
| 25 |
+
async def get_remediation():
|
| 26 |
+
"""
|
| 27 |
+
Return prioritized remediation interventions with expected impact estimates.
|
| 28 |
+
|
| 29 |
+
Intervention types:
|
| 30 |
+
- remove_proxy: Remove features acting as demographic proxies
|
| 31 |
+
- reweight_data: Reweight training samples to balance outcomes
|
| 32 |
+
- adjust_threshold: Group-specific decision threshold tuning
|
| 33 |
+
- post_process: Post-processing calibration
|
| 34 |
+
"""
|
| 35 |
+
df = get_dataset()
|
| 36 |
+
|
| 37 |
+
interventions = generate_interventions(df)
|
| 38 |
+
constraints = get_fairness_constraints()
|
| 39 |
+
|
| 40 |
+
di = compute_disparate_impact(df, "race")
|
| 41 |
+
|
| 42 |
+
return RemediationResponse(
|
| 43 |
+
current_disparate_impact=di["ratio"],
|
| 44 |
+
target_disparate_impact=0.80,
|
| 45 |
+
interventions=interventions,
|
| 46 |
+
fairness_constraints=constraints,
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@router.post("/remediation/apply", response_model=ApplyRemediationResponse)
|
| 51 |
+
async def apply_remediation_endpoint(
|
| 52 |
+
request: ApplyRemediationRequest,
|
| 53 |
+
protected_attr: str = Query(default="race"),
|
| 54 |
+
):
|
| 55 |
+
"""
|
| 56 |
+
Apply selected interventions and return before/after fairness comparison.
|
| 57 |
+
"""
|
| 58 |
+
df = get_dataset()
|
| 59 |
+
|
| 60 |
+
result = apply_remediation(
|
| 61 |
+
df,
|
| 62 |
+
intervention_ids=request.intervention_ids,
|
| 63 |
+
constraints=request.fairness_constraints,
|
| 64 |
+
protected_attr=protected_attr,
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
return ApplyRemediationResponse(**result)
|
backend/routers/simulator.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FairSight — Simulator Router
|
| 3 |
+
POST /api/simulator → what-if simulation with adjustable parameters
|
| 4 |
+
"""
|
| 5 |
+
from fastapi import APIRouter
|
| 6 |
+
|
| 7 |
+
from services.data_service import get_dataset
|
| 8 |
+
from services.simulator_service import simulate
|
| 9 |
+
from models.schemas import SimulatorRequest, SimulatorResult
|
| 10 |
+
|
| 11 |
+
router = APIRouter(prefix="/api", tags=["Simulator"])
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@router.post("/simulator", response_model=SimulatorResult)
|
| 15 |
+
async def run_simulation(request: SimulatorRequest):
|
| 16 |
+
"""
|
| 17 |
+
Run a what-if simulation to see how fairness metrics change
|
| 18 |
+
with different interventions, before touching the actual model.
|
| 19 |
+
|
| 20 |
+
Parameters:
|
| 21 |
+
- threshold_adjustment: shift the decision threshold
|
| 22 |
+
- reweighting_strength: how aggressively to uplift underrepresented groups
|
| 23 |
+
- calibration_strength: Platt-style calibration intensity
|
| 24 |
+
- remove_features: proxy features to exclude
|
| 25 |
+
- protected_attribute: attribute to evaluate against
|
| 26 |
+
"""
|
| 27 |
+
df = get_dataset()
|
| 28 |
+
|
| 29 |
+
result = simulate(
|
| 30 |
+
df,
|
| 31 |
+
threshold_adjustment=request.threshold_adjustment,
|
| 32 |
+
reweighting_strength=request.reweighting_strength,
|
| 33 |
+
calibration_strength=request.calibration_strength,
|
| 34 |
+
remove_features=request.remove_features,
|
| 35 |
+
protected_attribute=request.protected_attribute,
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
return SimulatorResult(**result)
|
backend/services/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# FairSight Services Package
|
backend/services/alert_service.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FairSight — Alert Service
|
| 3 |
+
Generates priority-ordered alerts for direct discrimination and proxy variable issues.
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import uuid
|
| 8 |
+
import pandas as pd
|
| 9 |
+
|
| 10 |
+
from models.enums import Severity, AlertType
|
| 11 |
+
from services.fairness_engine import (
|
| 12 |
+
compute_disparate_impact,
|
| 13 |
+
compute_equal_opportunity_gap,
|
| 14 |
+
compute_calibration_gap,
|
| 15 |
+
)
|
| 16 |
+
from services.proxy_detector import detect_proxy_variables
|
| 17 |
+
from config import (
|
| 18 |
+
DI_CRITICAL_THRESHOLD,
|
| 19 |
+
DI_WARNING_THRESHOLD,
|
| 20 |
+
EO_CRITICAL_THRESHOLD,
|
| 21 |
+
EO_WARNING_THRESHOLD,
|
| 22 |
+
PROTECTED_ATTRIBUTES,
|
| 23 |
+
FEATURE_COLUMNS,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def generate_alerts(df: pd.DataFrame) -> list[dict]:
|
| 28 |
+
"""
|
| 29 |
+
Analyze the dataset and generate a prioritized list of alerts.
|
| 30 |
+
Combines direct discrimination detection with proxy variable detection.
|
| 31 |
+
"""
|
| 32 |
+
alerts: list[dict] = []
|
| 33 |
+
|
| 34 |
+
# ── 1. Direct discrimination alerts (per protected attribute) ─────────
|
| 35 |
+
for attr in PROTECTED_ATTRIBUTES:
|
| 36 |
+
if attr not in df.columns:
|
| 37 |
+
continue
|
| 38 |
+
|
| 39 |
+
# Disparate impact alerts
|
| 40 |
+
di_result = compute_disparate_impact(df, attr)
|
| 41 |
+
di_ratio = di_result["ratio"]
|
| 42 |
+
group_rates = di_result.get("group_rates", {})
|
| 43 |
+
ref_group = di_result.get("reference_group", "")
|
| 44 |
+
|
| 45 |
+
if di_ratio < DI_CRITICAL_THRESHOLD:
|
| 46 |
+
# Find the most disadvantaged group
|
| 47 |
+
worst_group = min(group_rates, key=group_rates.get)
|
| 48 |
+
alerts.append({
|
| 49 |
+
"id": f"di-{attr}-{uuid.uuid4().hex[:8]}",
|
| 50 |
+
"type": AlertType.DIRECT_DISCRIMINATION.value,
|
| 51 |
+
"severity": Severity.CRITICAL.value,
|
| 52 |
+
"title": f"Critical Disparate Impact on {attr.title()}",
|
| 53 |
+
"description": (
|
| 54 |
+
f"The disparate impact ratio for '{attr}' is {di_ratio:.2f}, "
|
| 55 |
+
f"well below the EEOC 4/5ths threshold of 0.80. "
|
| 56 |
+
f"Group '{worst_group}' has an approval rate of "
|
| 57 |
+
f"{group_rates.get(worst_group, 0):.1%} compared to "
|
| 58 |
+
f"'{ref_group}' at {group_rates.get(ref_group, 0):.1%}."
|
| 59 |
+
),
|
| 60 |
+
"affected_groups": [worst_group, ref_group],
|
| 61 |
+
"affected_features": [],
|
| 62 |
+
"metric_value": di_ratio,
|
| 63 |
+
"threshold": DI_CRITICAL_THRESHOLD,
|
| 64 |
+
"recommendation": (
|
| 65 |
+
f"Review decision criteria for bias against '{worst_group}'. "
|
| 66 |
+
f"Consider threshold adjustments or reweighting training data."
|
| 67 |
+
),
|
| 68 |
+
})
|
| 69 |
+
elif di_ratio < DI_WARNING_THRESHOLD:
|
| 70 |
+
worst_group = min(group_rates, key=group_rates.get)
|
| 71 |
+
alerts.append({
|
| 72 |
+
"id": f"di-{attr}-{uuid.uuid4().hex[:8]}",
|
| 73 |
+
"type": AlertType.DIRECT_DISCRIMINATION.value,
|
| 74 |
+
"severity": Severity.WARNING.value,
|
| 75 |
+
"title": f"Disparate Impact Warning on {attr.title()}",
|
| 76 |
+
"description": (
|
| 77 |
+
f"The disparate impact ratio for '{attr}' is {di_ratio:.2f}, "
|
| 78 |
+
f"approaching the 0.80 threshold."
|
| 79 |
+
),
|
| 80 |
+
"affected_groups": list(group_rates.keys()),
|
| 81 |
+
"affected_features": [],
|
| 82 |
+
"metric_value": di_ratio,
|
| 83 |
+
"threshold": DI_WARNING_THRESHOLD,
|
| 84 |
+
"recommendation": f"Monitor {attr} disparate impact closely.",
|
| 85 |
+
})
|
| 86 |
+
|
| 87 |
+
# Equal opportunity alerts
|
| 88 |
+
eo_result = compute_equal_opportunity_gap(df, attr)
|
| 89 |
+
eo_gap = eo_result["gap"]
|
| 90 |
+
|
| 91 |
+
if eo_gap > EO_CRITICAL_THRESHOLD:
|
| 92 |
+
tpr_data = eo_result.get("tpr_by_group", {})
|
| 93 |
+
worst_eo_group = min(tpr_data, key=tpr_data.get) if tpr_data else ""
|
| 94 |
+
alerts.append({
|
| 95 |
+
"id": f"eo-{attr}-{uuid.uuid4().hex[:8]}",
|
| 96 |
+
"type": AlertType.DIRECT_DISCRIMINATION.value,
|
| 97 |
+
"severity": Severity.CRITICAL.value,
|
| 98 |
+
"title": f"Equal Opportunity Violation on {attr.title()}",
|
| 99 |
+
"description": (
|
| 100 |
+
f"Qualified individuals from '{worst_eo_group}' are {eo_gap:.1%} "
|
| 101 |
+
f"less likely to be approved than qualified individuals from other groups."
|
| 102 |
+
),
|
| 103 |
+
"affected_groups": list(tpr_data.keys()),
|
| 104 |
+
"affected_features": [],
|
| 105 |
+
"metric_value": eo_gap,
|
| 106 |
+
"threshold": EO_CRITICAL_THRESHOLD,
|
| 107 |
+
"recommendation": (
|
| 108 |
+
"Ensure qualified applicants receive equal consideration "
|
| 109 |
+
"regardless of group membership."
|
| 110 |
+
),
|
| 111 |
+
})
|
| 112 |
+
elif eo_gap > EO_WARNING_THRESHOLD:
|
| 113 |
+
alerts.append({
|
| 114 |
+
"id": f"eo-{attr}-{uuid.uuid4().hex[:8]}",
|
| 115 |
+
"type": AlertType.DIRECT_DISCRIMINATION.value,
|
| 116 |
+
"severity": Severity.WARNING.value,
|
| 117 |
+
"title": f"Equal Opportunity Gap on {attr.title()}",
|
| 118 |
+
"description": f"An equal opportunity gap of {eo_gap:.1%} was detected for '{attr}'.",
|
| 119 |
+
"affected_groups": list(eo_result.get("tpr_by_group", {}).keys()),
|
| 120 |
+
"affected_features": [],
|
| 121 |
+
"metric_value": eo_gap,
|
| 122 |
+
"threshold": EO_WARNING_THRESHOLD,
|
| 123 |
+
"recommendation": "Review model for potential equal opportunity issues.",
|
| 124 |
+
})
|
| 125 |
+
|
| 126 |
+
# ── 2. Proxy variable alerts ──────────────────────────────────────────
|
| 127 |
+
available_features = [f for f in FEATURE_COLUMNS if f in df.columns]
|
| 128 |
+
available_attrs = [a for a in PROTECTED_ATTRIBUTES if a in df.columns]
|
| 129 |
+
|
| 130 |
+
proxies = detect_proxy_variables(df, available_features, available_attrs)
|
| 131 |
+
|
| 132 |
+
for proxy in proxies:
|
| 133 |
+
alerts.append({
|
| 134 |
+
"id": f"proxy-{proxy['feature']}-{proxy['protected_attribute']}-{uuid.uuid4().hex[:8]}",
|
| 135 |
+
"type": AlertType.PROXY_VARIABLE.value,
|
| 136 |
+
"severity": proxy["severity"],
|
| 137 |
+
"title": f"Proxy Variable Detected: {proxy['feature']}",
|
| 138 |
+
"description": proxy["description"],
|
| 139 |
+
"affected_groups": [],
|
| 140 |
+
"affected_features": [proxy["feature"]],
|
| 141 |
+
"metric_value": proxy["correlation"],
|
| 142 |
+
"threshold": 0.15,
|
| 143 |
+
"recommendation": (
|
| 144 |
+
f"Consider removing or transforming '{proxy['feature']}' to prevent "
|
| 145 |
+
f"indirect discrimination via '{proxy['protected_attribute']}'."
|
| 146 |
+
),
|
| 147 |
+
})
|
| 148 |
+
|
| 149 |
+
# ── 3. Sort by priority: critical > warning > info ────────────────────
|
| 150 |
+
severity_order = {Severity.CRITICAL.value: 0, Severity.WARNING.value: 1, Severity.INFO.value: 2}
|
| 151 |
+
alerts.sort(key=lambda a: (severity_order.get(a["severity"], 99), -(a.get("metric_value") or 0)))
|
| 152 |
+
|
| 153 |
+
return alerts
|
backend/services/data_service.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FairSight — Data Service
|
| 3 |
+
Generates a realistic synthetic dataset for bias-detection demos.
|
| 4 |
+
Handles loading, validation, and profiling of datasets.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import numpy as np
|
| 10 |
+
import pandas as pd
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# ── Singleton dataset cache ───────────────────────────────────────────────
|
| 15 |
+
_dataset_cache: pd.DataFrame | None = None
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _generate_synthetic_dataset(n: int = 5000, seed: int = 42) -> pd.DataFrame:
|
| 19 |
+
"""
|
| 20 |
+
Generate a synthetic lending/hiring dataset with realistic biases baked in
|
| 21 |
+
to demonstrate the detection capabilities of FairSight.
|
| 22 |
+
"""
|
| 23 |
+
rng = np.random.default_rng(seed)
|
| 24 |
+
|
| 25 |
+
# ── Demographics ──────────────────────────────────────────────────────
|
| 26 |
+
race_groups = ["White", "Black", "Hispanic", "Asian"]
|
| 27 |
+
race_weights = [0.35, 0.25, 0.25, 0.15]
|
| 28 |
+
race = rng.choice(race_groups, size=n, p=race_weights)
|
| 29 |
+
|
| 30 |
+
gender = rng.choice(["Male", "Female"], size=n, p=[0.52, 0.48])
|
| 31 |
+
|
| 32 |
+
age = np.clip(rng.normal(40, 12, size=n).astype(int), 18, 75)
|
| 33 |
+
disability = rng.choice(["Yes", "No"], size=n, p=[0.12, 0.88])
|
| 34 |
+
|
| 35 |
+
# ── Features ──────────────────────────────────────────────────────────
|
| 36 |
+
# Credit score: correlated with race (introducing bias)
|
| 37 |
+
base_credit = rng.normal(680, 80, size=n)
|
| 38 |
+
race_credit_bias = np.where(
|
| 39 |
+
race == "White", 20,
|
| 40 |
+
np.where(race == "Asian", 15,
|
| 41 |
+
np.where(race == "Hispanic", -10, -25))
|
| 42 |
+
)
|
| 43 |
+
credit_score = np.clip(base_credit + race_credit_bias, 300, 850).astype(int)
|
| 44 |
+
|
| 45 |
+
# Zip code: strongly correlated with race (proxy variable)
|
| 46 |
+
zip_base = np.where(
|
| 47 |
+
race == "White", rng.choice(range(10001, 10050), size=n),
|
| 48 |
+
np.where(race == "Black", rng.choice(range(10051, 10100), size=n),
|
| 49 |
+
np.where(race == "Hispanic", rng.choice(range(10101, 10150), size=n),
|
| 50 |
+
rng.choice(range(10151, 10200), size=n)))
|
| 51 |
+
)
|
| 52 |
+
zip_code = zip_base
|
| 53 |
+
|
| 54 |
+
# Income: correlated with gender and race
|
| 55 |
+
base_income = rng.lognormal(mean=10.8, sigma=0.5, size=n)
|
| 56 |
+
gender_income_bias = np.where(gender == "Male", 1.08, 0.92)
|
| 57 |
+
race_income_bias = np.where(
|
| 58 |
+
race == "White", 1.05,
|
| 59 |
+
np.where(race == "Asian", 1.03,
|
| 60 |
+
np.where(race == "Hispanic", 0.93, 0.88))
|
| 61 |
+
)
|
| 62 |
+
income = (base_income * gender_income_bias * race_income_bias).astype(int)
|
| 63 |
+
|
| 64 |
+
# Education level (1–5)
|
| 65 |
+
education = np.clip(rng.normal(3, 1, size=n).astype(int), 1, 5)
|
| 66 |
+
|
| 67 |
+
# Employment years
|
| 68 |
+
employment_years = np.clip(rng.exponential(8, size=n).astype(int), 0, 40)
|
| 69 |
+
|
| 70 |
+
# Debt ratio
|
| 71 |
+
debt_ratio = np.clip(rng.beta(2, 5, size=n), 0, 1).round(2)
|
| 72 |
+
|
| 73 |
+
# Loan amount
|
| 74 |
+
loan_amount = (rng.lognormal(mean=10, sigma=0.8, size=n)).astype(int)
|
| 75 |
+
|
| 76 |
+
# School name: another proxy for race/socioeconomic status
|
| 77 |
+
school_categories = np.where(
|
| 78 |
+
race == "White", rng.choice(["Ivy_League", "State_Univ", "Community_College"], size=n, p=[0.15, 0.55, 0.30]),
|
| 79 |
+
np.where(race == "Asian", rng.choice(["Ivy_League", "State_Univ", "Community_College"], size=n, p=[0.20, 0.50, 0.30]),
|
| 80 |
+
np.where(race == "Hispanic", rng.choice(["Ivy_League", "State_Univ", "Community_College"], size=n, p=[0.05, 0.40, 0.55]),
|
| 81 |
+
rng.choice(["Ivy_League", "State_Univ", "Community_College"], size=n, p=[0.05, 0.35, 0.60])))
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
# Neighborhood: socioeconomic proxy
|
| 85 |
+
neighborhood = np.where(
|
| 86 |
+
income > 70000, "Affluent",
|
| 87 |
+
np.where(income > 40000, "Middle_Class", "Low_Income")
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
# ── Qualification (ground truth) ──────────────────────────────────────
|
| 91 |
+
# Based on objective factors
|
| 92 |
+
qual_score = (
|
| 93 |
+
(credit_score - 300) / 550 * 0.35 +
|
| 94 |
+
np.clip(income / 100000, 0, 1) * 0.25 +
|
| 95 |
+
education / 5 * 0.15 +
|
| 96 |
+
np.clip(employment_years / 20, 0, 1) * 0.15 +
|
| 97 |
+
(1 - debt_ratio) * 0.10
|
| 98 |
+
)
|
| 99 |
+
qualified = (qual_score > 0.45).astype(int)
|
| 100 |
+
|
| 101 |
+
# ── Decision (model output) — includes bias ──────────────────────────
|
| 102 |
+
# Start from qualification, then add discriminatory bias
|
| 103 |
+
decision_score = qual_score.copy()
|
| 104 |
+
|
| 105 |
+
# Race bias in decisions
|
| 106 |
+
race_decision_bias = np.where(
|
| 107 |
+
race == "White", 0.06,
|
| 108 |
+
np.where(race == "Asian", 0.02,
|
| 109 |
+
np.where(race == "Hispanic", -0.04, -0.10))
|
| 110 |
+
)
|
| 111 |
+
decision_score += race_decision_bias
|
| 112 |
+
|
| 113 |
+
# Gender bias
|
| 114 |
+
gender_decision_bias = np.where(gender == "Male", 0.03, -0.03)
|
| 115 |
+
decision_score += gender_decision_bias
|
| 116 |
+
|
| 117 |
+
# Age bias (against older applicants)
|
| 118 |
+
age_decision_bias = np.where(age > 55, -0.05, np.where(age < 25, -0.02, 0.0))
|
| 119 |
+
decision_score += age_decision_bias
|
| 120 |
+
|
| 121 |
+
# Add noise
|
| 122 |
+
decision_score += rng.normal(0, 0.05, size=n)
|
| 123 |
+
|
| 124 |
+
decision = (decision_score > 0.50).astype(int)
|
| 125 |
+
|
| 126 |
+
# ── Predicted probability (model confidence) ──────────────────────────
|
| 127 |
+
predicted_prob = np.clip(decision_score + rng.normal(0, 0.08, size=n), 0, 1).round(3)
|
| 128 |
+
|
| 129 |
+
# ── Assemble DataFrame ────────────────────────────────────────────────
|
| 130 |
+
df = pd.DataFrame({
|
| 131 |
+
"gender": gender,
|
| 132 |
+
"race": race,
|
| 133 |
+
"age": age,
|
| 134 |
+
"disability": disability,
|
| 135 |
+
"credit_score": credit_score,
|
| 136 |
+
"zip_code": zip_code,
|
| 137 |
+
"income": income,
|
| 138 |
+
"education": education,
|
| 139 |
+
"employment_years": employment_years,
|
| 140 |
+
"debt_ratio": debt_ratio,
|
| 141 |
+
"loan_amount": loan_amount,
|
| 142 |
+
"school_name": school_categories,
|
| 143 |
+
"neighborhood": neighborhood,
|
| 144 |
+
"qualified": qualified,
|
| 145 |
+
"decision": decision,
|
| 146 |
+
"predicted_prob": predicted_prob,
|
| 147 |
+
})
|
| 148 |
+
|
| 149 |
+
return df
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def get_dataset() -> pd.DataFrame:
|
| 153 |
+
"""Return the cached dataset, generating it if needed."""
|
| 154 |
+
global _dataset_cache
|
| 155 |
+
|
| 156 |
+
if _dataset_cache is not None:
|
| 157 |
+
return _dataset_cache
|
| 158 |
+
|
| 159 |
+
data_dir = Path(__file__).parent.parent / "data"
|
| 160 |
+
data_path = data_dir / "sample_dataset.csv"
|
| 161 |
+
|
| 162 |
+
if data_path.exists():
|
| 163 |
+
_dataset_cache = pd.read_csv(data_path)
|
| 164 |
+
else:
|
| 165 |
+
os.makedirs(data_dir, exist_ok=True)
|
| 166 |
+
_dataset_cache = _generate_synthetic_dataset()
|
| 167 |
+
_dataset_cache.to_csv(data_path, index=False)
|
| 168 |
+
|
| 169 |
+
return _dataset_cache
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def reload_dataset(df: pd.DataFrame | None = None) -> pd.DataFrame:
|
| 173 |
+
"""Force-reload or replace dataset."""
|
| 174 |
+
global _dataset_cache
|
| 175 |
+
if df is not None:
|
| 176 |
+
_dataset_cache = df
|
| 177 |
+
else:
|
| 178 |
+
_dataset_cache = None
|
| 179 |
+
return get_dataset()
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def get_dataset_info() -> dict:
|
| 183 |
+
"""Return metadata about the loaded dataset."""
|
| 184 |
+
df = get_dataset()
|
| 185 |
+
protected_found = [c for c in ["gender", "race", "age", "disability"] if c in df.columns]
|
| 186 |
+
return {
|
| 187 |
+
"rows": len(df),
|
| 188 |
+
"columns": len(df.columns),
|
| 189 |
+
"column_names": list(df.columns),
|
| 190 |
+
"protected_attributes_found": protected_found,
|
| 191 |
+
"decision_column": "decision",
|
| 192 |
+
"qualification_column": "qualified",
|
| 193 |
+
}
|
backend/services/fairness_engine.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FairSight — Fairness Engine
|
| 3 |
+
Core fairness metric computations: disparate impact, equal opportunity gap,
|
| 4 |
+
calibration gap, and per-group breakdowns.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
import pandas as pd
|
| 10 |
+
from typing import Optional
|
| 11 |
+
|
| 12 |
+
from config import (
|
| 13 |
+
DI_CRITICAL_THRESHOLD,
|
| 14 |
+
DI_WARNING_THRESHOLD,
|
| 15 |
+
EO_CRITICAL_THRESHOLD,
|
| 16 |
+
EO_WARNING_THRESHOLD,
|
| 17 |
+
CAL_CRITICAL_THRESHOLD,
|
| 18 |
+
CAL_WARNING_THRESHOLD,
|
| 19 |
+
)
|
| 20 |
+
from models.enums import MetricStatus
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 24 |
+
# Disparate Impact
|
| 25 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 26 |
+
|
| 27 |
+
def compute_disparate_impact(
|
| 28 |
+
df: pd.DataFrame,
|
| 29 |
+
protected_attr: str,
|
| 30 |
+
decision_col: str = "decision",
|
| 31 |
+
reference_group: Optional[str] = None,
|
| 32 |
+
) -> dict:
|
| 33 |
+
"""
|
| 34 |
+
Compute disparate impact ratio.
|
| 35 |
+
|
| 36 |
+
DI = P(decision=1 | unprivileged) / P(decision=1 | privileged)
|
| 37 |
+
|
| 38 |
+
Returns the minimum DI across all group comparisons (worst-case).
|
| 39 |
+
"""
|
| 40 |
+
groups = df[protected_attr].unique()
|
| 41 |
+
approval_rates = {}
|
| 42 |
+
|
| 43 |
+
for g in groups:
|
| 44 |
+
mask = df[protected_attr] == g
|
| 45 |
+
n_group = mask.sum()
|
| 46 |
+
if n_group == 0:
|
| 47 |
+
continue
|
| 48 |
+
approval_rates[g] = df.loc[mask, decision_col].mean()
|
| 49 |
+
|
| 50 |
+
if not approval_rates:
|
| 51 |
+
return {"ratio": 1.0, "status": MetricStatus.PASSING, "group_rates": {}}
|
| 52 |
+
|
| 53 |
+
# Reference group = highest approval rate (privileged)
|
| 54 |
+
if reference_group is None or reference_group not in approval_rates:
|
| 55 |
+
reference_group = max(approval_rates, key=approval_rates.get)
|
| 56 |
+
|
| 57 |
+
ref_rate = approval_rates[reference_group]
|
| 58 |
+
if ref_rate == 0:
|
| 59 |
+
return {"ratio": 0.0, "status": MetricStatus.CRITICAL, "group_rates": approval_rates, "reference_group": reference_group}
|
| 60 |
+
|
| 61 |
+
# Compute DI for each non-reference group
|
| 62 |
+
di_ratios = {}
|
| 63 |
+
for g, rate in approval_rates.items():
|
| 64 |
+
if g == reference_group:
|
| 65 |
+
di_ratios[g] = 1.0
|
| 66 |
+
else:
|
| 67 |
+
di_ratios[g] = rate / ref_rate
|
| 68 |
+
|
| 69 |
+
min_di = min(di_ratios.values())
|
| 70 |
+
|
| 71 |
+
if min_di < DI_CRITICAL_THRESHOLD:
|
| 72 |
+
status = MetricStatus.CRITICAL
|
| 73 |
+
elif min_di < DI_WARNING_THRESHOLD:
|
| 74 |
+
status = MetricStatus.NEEDS_REVIEW
|
| 75 |
+
else:
|
| 76 |
+
status = MetricStatus.PASSING
|
| 77 |
+
|
| 78 |
+
return {
|
| 79 |
+
"ratio": round(min_di, 3),
|
| 80 |
+
"status": status,
|
| 81 |
+
"group_rates": {g: round(r, 3) for g, r in approval_rates.items()},
|
| 82 |
+
"group_di_ratios": {g: round(r, 3) for g, r in di_ratios.items()},
|
| 83 |
+
"reference_group": reference_group,
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 88 |
+
# Equal Opportunity Gap
|
| 89 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 90 |
+
|
| 91 |
+
def compute_equal_opportunity_gap(
|
| 92 |
+
df: pd.DataFrame,
|
| 93 |
+
protected_attr: str,
|
| 94 |
+
decision_col: str = "decision",
|
| 95 |
+
qualification_col: str = "qualified",
|
| 96 |
+
reference_group: Optional[str] = None,
|
| 97 |
+
) -> dict:
|
| 98 |
+
"""
|
| 99 |
+
Equal Opportunity Gap = |TPR_privileged − TPR_unprivileged|
|
| 100 |
+
|
| 101 |
+
TPR = P(decision=1 | qualified=1, group)
|
| 102 |
+
"""
|
| 103 |
+
groups = df[protected_attr].unique()
|
| 104 |
+
tpr_by_group = {}
|
| 105 |
+
|
| 106 |
+
for g in groups:
|
| 107 |
+
mask = (df[protected_attr] == g) & (df[qualification_col] == 1)
|
| 108 |
+
n_qualified = mask.sum()
|
| 109 |
+
if n_qualified == 0:
|
| 110 |
+
continue
|
| 111 |
+
tpr_by_group[g] = df.loc[mask, decision_col].mean()
|
| 112 |
+
|
| 113 |
+
if not tpr_by_group:
|
| 114 |
+
return {"gap": 0.0, "status": MetricStatus.PASSING, "tpr_by_group": {}}
|
| 115 |
+
|
| 116 |
+
if reference_group is None or reference_group not in tpr_by_group:
|
| 117 |
+
reference_group = max(tpr_by_group, key=tpr_by_group.get)
|
| 118 |
+
|
| 119 |
+
ref_tpr = tpr_by_group[reference_group]
|
| 120 |
+
|
| 121 |
+
gaps = {}
|
| 122 |
+
for g, tpr in tpr_by_group.items():
|
| 123 |
+
gaps[g] = abs(ref_tpr - tpr)
|
| 124 |
+
|
| 125 |
+
max_gap = max(gaps.values())
|
| 126 |
+
|
| 127 |
+
if max_gap > EO_CRITICAL_THRESHOLD:
|
| 128 |
+
status = MetricStatus.CRITICAL
|
| 129 |
+
elif max_gap > EO_WARNING_THRESHOLD:
|
| 130 |
+
status = MetricStatus.NEEDS_REVIEW
|
| 131 |
+
else:
|
| 132 |
+
status = MetricStatus.PASSING
|
| 133 |
+
|
| 134 |
+
return {
|
| 135 |
+
"gap": round(max_gap, 3),
|
| 136 |
+
"status": status,
|
| 137 |
+
"tpr_by_group": {g: round(v, 3) for g, v in tpr_by_group.items()},
|
| 138 |
+
"gaps": {g: round(v, 3) for g, v in gaps.items()},
|
| 139 |
+
"reference_group": reference_group,
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
# ════════════════════════════════���══════════════════════════════════════════
|
| 144 |
+
# Calibration Gap
|
| 145 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 146 |
+
|
| 147 |
+
def compute_calibration_gap(
|
| 148 |
+
df: pd.DataFrame,
|
| 149 |
+
protected_attr: str,
|
| 150 |
+
predicted_col: str = "predicted_prob",
|
| 151 |
+
qualification_col: str = "qualified",
|
| 152 |
+
n_bins: int = 10,
|
| 153 |
+
) -> dict:
|
| 154 |
+
"""
|
| 155 |
+
Calibration Gap: measures how well predictions match reality
|
| 156 |
+
across demographic groups.
|
| 157 |
+
|
| 158 |
+
Bins predicted probabilities and compares actual positive rate
|
| 159 |
+
per bin across groups.
|
| 160 |
+
"""
|
| 161 |
+
groups = df[protected_attr].unique()
|
| 162 |
+
calibrations = {}
|
| 163 |
+
|
| 164 |
+
for g in groups:
|
| 165 |
+
mask = df[protected_attr] == g
|
| 166 |
+
subset = df.loc[mask]
|
| 167 |
+
if len(subset) < 20:
|
| 168 |
+
continue
|
| 169 |
+
|
| 170 |
+
bins = pd.cut(subset[predicted_col], bins=n_bins, labels=False, duplicates="drop")
|
| 171 |
+
if bins.isna().all():
|
| 172 |
+
continue
|
| 173 |
+
|
| 174 |
+
bin_stats = subset.groupby(bins).agg(
|
| 175 |
+
predicted_mean=(predicted_col, "mean"),
|
| 176 |
+
actual_mean=(qualification_col, "mean"),
|
| 177 |
+
count=(qualification_col, "count"),
|
| 178 |
+
)
|
| 179 |
+
# Weighted average calibration error for this group
|
| 180 |
+
total = bin_stats["count"].sum()
|
| 181 |
+
if total == 0:
|
| 182 |
+
continue
|
| 183 |
+
weighted_error = (
|
| 184 |
+
(bin_stats["predicted_mean"] - bin_stats["actual_mean"]).abs() *
|
| 185 |
+
bin_stats["count"] / total
|
| 186 |
+
).sum()
|
| 187 |
+
|
| 188 |
+
calibrations[g] = round(weighted_error, 4)
|
| 189 |
+
|
| 190 |
+
if not calibrations:
|
| 191 |
+
return {"gap": 0.0, "status": MetricStatus.PASSING, "calibrations": {}}
|
| 192 |
+
|
| 193 |
+
# Gap = max difference in calibration error between groups
|
| 194 |
+
cal_values = list(calibrations.values())
|
| 195 |
+
gap = max(cal_values) - min(cal_values)
|
| 196 |
+
|
| 197 |
+
if gap > CAL_CRITICAL_THRESHOLD:
|
| 198 |
+
status = MetricStatus.CRITICAL
|
| 199 |
+
elif gap > CAL_WARNING_THRESHOLD:
|
| 200 |
+
status = MetricStatus.NEEDS_REVIEW
|
| 201 |
+
else:
|
| 202 |
+
status = MetricStatus.PASSING
|
| 203 |
+
|
| 204 |
+
return {
|
| 205 |
+
"gap": round(gap, 4),
|
| 206 |
+
"status": status,
|
| 207 |
+
"calibrations": calibrations,
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 212 |
+
# Scatter Plot Data (predicted vs actual)
|
| 213 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 214 |
+
|
| 215 |
+
def compute_scatter_data(
|
| 216 |
+
df: pd.DataFrame,
|
| 217 |
+
protected_attr: str,
|
| 218 |
+
predicted_col: str = "predicted_prob",
|
| 219 |
+
qualification_col: str = "qualified",
|
| 220 |
+
sample_size: int = 300,
|
| 221 |
+
) -> list[dict]:
|
| 222 |
+
"""
|
| 223 |
+
Produce scatter-plot data: predicted probability vs. actual qualification,
|
| 224 |
+
color-coded by group. Downsample for frontend performance.
|
| 225 |
+
"""
|
| 226 |
+
sampled = df.sample(n=min(sample_size, len(df)), random_state=42)
|
| 227 |
+
|
| 228 |
+
points = []
|
| 229 |
+
for _, row in sampled.iterrows():
|
| 230 |
+
points.append({
|
| 231 |
+
"actual": float(row[qualification_col]),
|
| 232 |
+
"predicted": float(row[predicted_col]),
|
| 233 |
+
"group": str(row[protected_attr]),
|
| 234 |
+
})
|
| 235 |
+
|
| 236 |
+
return points
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 240 |
+
# Aggregate Fairness Score (0–100)
|
| 241 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 242 |
+
|
| 243 |
+
def compute_overall_fairness_score(
|
| 244 |
+
di_ratio: float,
|
| 245 |
+
eo_gap: float,
|
| 246 |
+
cal_gap: float,
|
| 247 |
+
) -> float:
|
| 248 |
+
"""
|
| 249 |
+
Combine the three metrics into a single 0-100 score.
|
| 250 |
+
|
| 251 |
+
Weights: DI=40%, EO=35%, Calibration=25%
|
| 252 |
+
Each sub-score is scaled so that threshold-passing = 90+
|
| 253 |
+
"""
|
| 254 |
+
# DI score: 1.0 → 100, 0.80 → 70, 0.60 → 40
|
| 255 |
+
di_score = min(100, max(0, (di_ratio / 1.0) * 100))
|
| 256 |
+
|
| 257 |
+
# EO score: 0.0 gap → 100, 0.15 gap → 50
|
| 258 |
+
eo_score = min(100, max(0, (1 - eo_gap / 0.30) * 100))
|
| 259 |
+
|
| 260 |
+
# Cal score: 0.0 gap → 100, 0.12 gap → 60
|
| 261 |
+
cal_score = min(100, max(0, (1 - cal_gap / 0.30) * 100))
|
| 262 |
+
|
| 263 |
+
overall = di_score * 0.40 + eo_score * 0.35 + cal_score * 0.25
|
| 264 |
+
return round(overall, 1)
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def get_metric_status_label(status: MetricStatus) -> str:
|
| 268 |
+
"""Convert MetricStatus enum to display label."""
|
| 269 |
+
return {
|
| 270 |
+
MetricStatus.PASSING: "Passing",
|
| 271 |
+
MetricStatus.NEEDS_REVIEW: "Needs review",
|
| 272 |
+
MetricStatus.CRITICAL: "Critical issue",
|
| 273 |
+
}[status]
|
backend/services/proxy_detector.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FairSight — Proxy Variable Detector
|
| 3 |
+
Identifies features that appear neutral but encode protected characteristics
|
| 4 |
+
(e.g. ZIP code acting as a racial proxy, school name encoding socioeconomic status).
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
import pandas as pd
|
| 10 |
+
from scipy.stats import chi2_contingency
|
| 11 |
+
from sklearn.preprocessing import LabelEncoder
|
| 12 |
+
|
| 13 |
+
from config import PROXY_CORRELATION_THRESHOLD, PROXY_HIGH_THRESHOLD
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _cramers_v(x: pd.Series, y: pd.Series) -> float:
|
| 17 |
+
"""
|
| 18 |
+
Compute Cramér's V statistic for two categorical variables.
|
| 19 |
+
Returns a value between 0 (no association) and 1 (perfect association).
|
| 20 |
+
"""
|
| 21 |
+
# Build a contingency table
|
| 22 |
+
contingency = pd.crosstab(x, y)
|
| 23 |
+
|
| 24 |
+
if contingency.shape[0] < 2 or contingency.shape[1] < 2:
|
| 25 |
+
return 0.0
|
| 26 |
+
|
| 27 |
+
chi2, _, _, _ = chi2_contingency(contingency)
|
| 28 |
+
n = contingency.sum().sum()
|
| 29 |
+
|
| 30 |
+
min_dim = min(contingency.shape[0], contingency.shape[1]) - 1
|
| 31 |
+
if min_dim == 0 or n == 0:
|
| 32 |
+
return 0.0
|
| 33 |
+
|
| 34 |
+
return float(np.sqrt(chi2 / (n * min_dim)))
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _point_biserial_like(feature: pd.Series, protected: pd.Series) -> float:
|
| 38 |
+
"""
|
| 39 |
+
For a numeric feature and a categorical protected attribute,
|
| 40 |
+
compute a normalized between-group variance measure (eta-squared).
|
| 41 |
+
"""
|
| 42 |
+
groups = protected.unique()
|
| 43 |
+
grand_mean = feature.mean()
|
| 44 |
+
|
| 45 |
+
ss_between = 0.0
|
| 46 |
+
ss_total = ((feature - grand_mean) ** 2).sum()
|
| 47 |
+
|
| 48 |
+
for g in groups:
|
| 49 |
+
mask = protected == g
|
| 50 |
+
group_mean = feature[mask].mean()
|
| 51 |
+
n_g = mask.sum()
|
| 52 |
+
ss_between += n_g * (group_mean - grand_mean) ** 2
|
| 53 |
+
|
| 54 |
+
if ss_total == 0:
|
| 55 |
+
return 0.0
|
| 56 |
+
|
| 57 |
+
eta_sq = ss_between / ss_total
|
| 58 |
+
return float(np.sqrt(eta_sq)) # return sqrt for a correlation-like scale
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def compute_feature_correlations(
|
| 62 |
+
df: pd.DataFrame,
|
| 63 |
+
feature_cols: list[str],
|
| 64 |
+
protected_attrs: list[str],
|
| 65 |
+
) -> list[dict]:
|
| 66 |
+
"""
|
| 67 |
+
Compute correlation between each feature and each protected attribute.
|
| 68 |
+
Uses Cramér's V for categorical-categorical and eta for numeric-categorical.
|
| 69 |
+
|
| 70 |
+
Returns a list of dicts: {feature, protected_attribute, correlation}
|
| 71 |
+
"""
|
| 72 |
+
results = []
|
| 73 |
+
|
| 74 |
+
for feat in feature_cols:
|
| 75 |
+
if feat not in df.columns:
|
| 76 |
+
continue
|
| 77 |
+
|
| 78 |
+
for attr in protected_attrs:
|
| 79 |
+
if attr not in df.columns:
|
| 80 |
+
continue
|
| 81 |
+
|
| 82 |
+
feat_series = df[feat].copy()
|
| 83 |
+
attr_series = df[attr].copy()
|
| 84 |
+
|
| 85 |
+
# Determine if feature is numeric or categorical
|
| 86 |
+
if pd.api.types.is_numeric_dtype(feat_series):
|
| 87 |
+
corr = _point_biserial_like(feat_series, attr_series)
|
| 88 |
+
else:
|
| 89 |
+
corr = _cramers_v(feat_series, attr_series)
|
| 90 |
+
|
| 91 |
+
results.append({
|
| 92 |
+
"feature": feat,
|
| 93 |
+
"protected_attribute": attr,
|
| 94 |
+
"correlation": round(corr, 2),
|
| 95 |
+
})
|
| 96 |
+
|
| 97 |
+
return results
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def detect_proxy_variables(
|
| 101 |
+
df: pd.DataFrame,
|
| 102 |
+
feature_cols: list[str],
|
| 103 |
+
protected_attrs: list[str],
|
| 104 |
+
threshold: float = PROXY_CORRELATION_THRESHOLD,
|
| 105 |
+
) -> list[dict]:
|
| 106 |
+
"""
|
| 107 |
+
Identify proxy variables — features whose correlation with a protected
|
| 108 |
+
attribute exceeds the threshold.
|
| 109 |
+
|
| 110 |
+
Returns list of {feature, protected_attribute, correlation, severity}
|
| 111 |
+
sorted by correlation descending.
|
| 112 |
+
"""
|
| 113 |
+
all_corrs = compute_feature_correlations(df, feature_cols, protected_attrs)
|
| 114 |
+
|
| 115 |
+
proxies = []
|
| 116 |
+
for item in all_corrs:
|
| 117 |
+
if item["correlation"] >= threshold:
|
| 118 |
+
severity = "critical" if item["correlation"] >= PROXY_HIGH_THRESHOLD else "warning"
|
| 119 |
+
proxies.append({
|
| 120 |
+
**item,
|
| 121 |
+
"severity": severity,
|
| 122 |
+
"description": (
|
| 123 |
+
f"'{item['feature']}' has a {item['correlation']:.2f} correlation "
|
| 124 |
+
f"with '{item['protected_attribute']}', potentially acting as a proxy."
|
| 125 |
+
),
|
| 126 |
+
})
|
| 127 |
+
|
| 128 |
+
proxies.sort(key=lambda x: x["correlation"], reverse=True)
|
| 129 |
+
return proxies
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def get_heatmap_data(
|
| 133 |
+
df: pd.DataFrame,
|
| 134 |
+
feature_cols: list[str],
|
| 135 |
+
protected_attrs: list[str],
|
| 136 |
+
) -> list[dict]:
|
| 137 |
+
"""
|
| 138 |
+
Return heatmap data (feature × protected attribute correlation matrix).
|
| 139 |
+
"""
|
| 140 |
+
return compute_feature_correlations(df, feature_cols, protected_attrs)
|
backend/services/remediation_service.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FairSight — Remediation Service
|
| 3 |
+
Generates prioritized, concrete interventions with expected impact estimates.
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import uuid
|
| 8 |
+
import numpy as np
|
| 9 |
+
import pandas as pd
|
| 10 |
+
|
| 11 |
+
from services.data_service import get_dataset
|
| 12 |
+
from services.fairness_engine import (
|
| 13 |
+
compute_disparate_impact,
|
| 14 |
+
compute_equal_opportunity_gap,
|
| 15 |
+
compute_calibration_gap,
|
| 16 |
+
)
|
| 17 |
+
from services.proxy_detector import detect_proxy_variables
|
| 18 |
+
from config import PROTECTED_ATTRIBUTES, FEATURE_COLUMNS
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _estimate_proxy_removal_impact(
|
| 22 |
+
df: pd.DataFrame,
|
| 23 |
+
proxy_feature: str,
|
| 24 |
+
protected_attr: str,
|
| 25 |
+
correlation: float,
|
| 26 |
+
) -> dict:
|
| 27 |
+
"""Estimate the DI/EO/Cal improvement from removing a proxy feature."""
|
| 28 |
+
# Higher correlation → larger expected improvement
|
| 29 |
+
di_improvement = min(0.15, correlation * 0.25)
|
| 30 |
+
eo_improvement = min(0.10, correlation * 0.15)
|
| 31 |
+
cal_improvement = min(0.05, correlation * 0.08)
|
| 32 |
+
|
| 33 |
+
return {
|
| 34 |
+
"di": round(di_improvement, 3),
|
| 35 |
+
"eo": round(eo_improvement, 3),
|
| 36 |
+
"cal": round(cal_improvement, 3),
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _estimate_reweight_impact(df: pd.DataFrame, protected_attr: str) -> dict:
|
| 41 |
+
"""Estimate impact of reweighting training data for a protected attribute."""
|
| 42 |
+
di = compute_disparate_impact(df, protected_attr)
|
| 43 |
+
gap_from_parity = max(0, 1.0 - di["ratio"])
|
| 44 |
+
|
| 45 |
+
# Reweighting typically recovers 40–60% of the gap
|
| 46 |
+
di_improvement = round(gap_from_parity * 0.50, 3)
|
| 47 |
+
eo_improvement = round(gap_from_parity * 0.35, 3)
|
| 48 |
+
cal_improvement = round(gap_from_parity * 0.10, 3)
|
| 49 |
+
|
| 50 |
+
return {"di": di_improvement, "eo": eo_improvement, "cal": cal_improvement}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _estimate_threshold_impact(df: pd.DataFrame, protected_attr: str) -> dict:
|
| 54 |
+
"""Estimate impact of group-specific threshold adjustment."""
|
| 55 |
+
di = compute_disparate_impact(df, protected_attr)
|
| 56 |
+
gap = max(0, 1.0 - di["ratio"])
|
| 57 |
+
|
| 58 |
+
di_improvement = round(gap * 0.70, 3)
|
| 59 |
+
eo_improvement = round(gap * 0.20, 3)
|
| 60 |
+
cal_improvement = round(gap * 0.05, 3)
|
| 61 |
+
|
| 62 |
+
return {"di": di_improvement, "eo": eo_improvement, "cal": cal_improvement}
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def generate_interventions(df: pd.DataFrame) -> list[dict]:
|
| 66 |
+
"""
|
| 67 |
+
Generate a prioritized list of remediation interventions.
|
| 68 |
+
"""
|
| 69 |
+
interventions: list[dict] = []
|
| 70 |
+
priority_counter = 1
|
| 71 |
+
|
| 72 |
+
available_features = [f for f in FEATURE_COLUMNS if f in df.columns]
|
| 73 |
+
available_attrs = [a for a in PROTECTED_ATTRIBUTES if a in df.columns]
|
| 74 |
+
|
| 75 |
+
# ── 1. Proxy removal recommendations ──────────────────────────────────
|
| 76 |
+
proxies = detect_proxy_variables(df, available_features, available_attrs)
|
| 77 |
+
|
| 78 |
+
for proxy in proxies:
|
| 79 |
+
impact = _estimate_proxy_removal_impact(
|
| 80 |
+
df, proxy["feature"], proxy["protected_attribute"], proxy["correlation"]
|
| 81 |
+
)
|
| 82 |
+
interventions.append({
|
| 83 |
+
"id": f"rem-proxy-{proxy['feature']}-{uuid.uuid4().hex[:8]}",
|
| 84 |
+
"type": "remove_proxy",
|
| 85 |
+
"title": f"Remove proxy feature: {proxy['feature']}",
|
| 86 |
+
"description": (
|
| 87 |
+
f"'{proxy['feature']}' correlates {proxy['correlation']:.2f} with "
|
| 88 |
+
f"'{proxy['protected_attribute']}'. Removing or transforming this feature "
|
| 89 |
+
f"would reduce indirect discrimination."
|
| 90 |
+
),
|
| 91 |
+
"priority": priority_counter,
|
| 92 |
+
"expected_di_improvement": impact["di"],
|
| 93 |
+
"expected_eo_improvement": impact["eo"],
|
| 94 |
+
"expected_cal_improvement": impact["cal"],
|
| 95 |
+
"risk_level": "low" if proxy["correlation"] < 0.30 else "medium",
|
| 96 |
+
"implementation_effort": "low",
|
| 97 |
+
"affected_features": [proxy["feature"]],
|
| 98 |
+
})
|
| 99 |
+
priority_counter += 1
|
| 100 |
+
|
| 101 |
+
# ── 2. Reweighting recommendations (per protected attr) ───────────────
|
| 102 |
+
for attr in available_attrs:
|
| 103 |
+
di = compute_disparate_impact(df, attr)
|
| 104 |
+
if di["ratio"] < 0.90:
|
| 105 |
+
impact = _estimate_reweight_impact(df, attr)
|
| 106 |
+
interventions.append({
|
| 107 |
+
"id": f"rem-reweight-{attr}-{uuid.uuid4().hex[:8]}",
|
| 108 |
+
"type": "reweight_data",
|
| 109 |
+
"title": f"Reweight training data for {attr}",
|
| 110 |
+
"description": (
|
| 111 |
+
f"Apply sample weights to balance representation and outcomes "
|
| 112 |
+
f"across '{attr}' groups. This adjusts the training objective without "
|
| 113 |
+
f"changing the feature set."
|
| 114 |
+
),
|
| 115 |
+
"priority": priority_counter,
|
| 116 |
+
"expected_di_improvement": impact["di"],
|
| 117 |
+
"expected_eo_improvement": impact["eo"],
|
| 118 |
+
"expected_cal_improvement": impact["cal"],
|
| 119 |
+
"risk_level": "medium",
|
| 120 |
+
"implementation_effort": "medium",
|
| 121 |
+
"affected_features": [],
|
| 122 |
+
})
|
| 123 |
+
priority_counter += 1
|
| 124 |
+
|
| 125 |
+
# ── 3. Threshold adjustment recommendations ─────���────────────────────
|
| 126 |
+
for attr in available_attrs:
|
| 127 |
+
di = compute_disparate_impact(df, attr)
|
| 128 |
+
if di["ratio"] < 0.85:
|
| 129 |
+
impact = _estimate_threshold_impact(df, attr)
|
| 130 |
+
interventions.append({
|
| 131 |
+
"id": f"rem-threshold-{attr}-{uuid.uuid4().hex[:8]}",
|
| 132 |
+
"type": "adjust_threshold",
|
| 133 |
+
"title": f"Adjust decision thresholds for {attr}",
|
| 134 |
+
"description": (
|
| 135 |
+
f"Apply group-specific decision thresholds to equalize approval rates "
|
| 136 |
+
f"across '{attr}' groups. This is a post-processing intervention."
|
| 137 |
+
),
|
| 138 |
+
"priority": priority_counter,
|
| 139 |
+
"expected_di_improvement": impact["di"],
|
| 140 |
+
"expected_eo_improvement": impact["eo"],
|
| 141 |
+
"expected_cal_improvement": impact["cal"],
|
| 142 |
+
"risk_level": "high",
|
| 143 |
+
"implementation_effort": "low",
|
| 144 |
+
"affected_features": [],
|
| 145 |
+
})
|
| 146 |
+
priority_counter += 1
|
| 147 |
+
|
| 148 |
+
# ── 4. Post-processing calibration ────────────────────────────────────
|
| 149 |
+
for attr in available_attrs:
|
| 150 |
+
cal = compute_calibration_gap(df, attr)
|
| 151 |
+
if cal["gap"] > 0.05:
|
| 152 |
+
interventions.append({
|
| 153 |
+
"id": f"rem-calibrate-{attr}-{uuid.uuid4().hex[:8]}",
|
| 154 |
+
"type": "post_process",
|
| 155 |
+
"title": f"Calibrate predictions across {attr} groups",
|
| 156 |
+
"description": (
|
| 157 |
+
f"Apply Platt scaling or isotonic regression per '{attr}' group "
|
| 158 |
+
f"to ensure predicted probabilities match actual outcomes equally."
|
| 159 |
+
),
|
| 160 |
+
"priority": priority_counter,
|
| 161 |
+
"expected_di_improvement": 0.02,
|
| 162 |
+
"expected_eo_improvement": 0.03,
|
| 163 |
+
"expected_cal_improvement": round(cal["gap"] * 0.60, 3),
|
| 164 |
+
"risk_level": "low",
|
| 165 |
+
"implementation_effort": "medium",
|
| 166 |
+
"affected_features": [],
|
| 167 |
+
})
|
| 168 |
+
priority_counter += 1
|
| 169 |
+
|
| 170 |
+
return interventions
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def get_fairness_constraints() -> list[dict]:
|
| 174 |
+
"""
|
| 175 |
+
Return adjustable fairness constraint sliders.
|
| 176 |
+
"""
|
| 177 |
+
return [
|
| 178 |
+
{
|
| 179 |
+
"name": "min_disparate_impact",
|
| 180 |
+
"current_value": 0.80,
|
| 181 |
+
"min_value": 0.50,
|
| 182 |
+
"max_value": 1.00,
|
| 183 |
+
"step": 0.05,
|
| 184 |
+
"description": "Minimum acceptable disparate impact ratio (EEOC 4/5ths rule = 0.80)",
|
| 185 |
+
},
|
| 186 |
+
{
|
| 187 |
+
"name": "max_equal_opportunity_gap",
|
| 188 |
+
"current_value": 0.10,
|
| 189 |
+
"min_value": 0.00,
|
| 190 |
+
"max_value": 0.30,
|
| 191 |
+
"step": 0.01,
|
| 192 |
+
"description": "Maximum acceptable equal opportunity gap between groups",
|
| 193 |
+
},
|
| 194 |
+
{
|
| 195 |
+
"name": "max_calibration_gap",
|
| 196 |
+
"current_value": 0.08,
|
| 197 |
+
"min_value": 0.00,
|
| 198 |
+
"max_value": 0.20,
|
| 199 |
+
"step": 0.01,
|
| 200 |
+
"description": "Maximum acceptable calibration gap between groups",
|
| 201 |
+
},
|
| 202 |
+
{
|
| 203 |
+
"name": "min_accuracy",
|
| 204 |
+
"current_value": 0.75,
|
| 205 |
+
"min_value": 0.50,
|
| 206 |
+
"max_value": 0.95,
|
| 207 |
+
"step": 0.01,
|
| 208 |
+
"description": "Minimum acceptable overall model accuracy after remediation",
|
| 209 |
+
},
|
| 210 |
+
]
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def apply_remediation(
|
| 214 |
+
df: pd.DataFrame,
|
| 215 |
+
intervention_ids: list[str],
|
| 216 |
+
constraints: dict[str, float],
|
| 217 |
+
protected_attr: str = "race",
|
| 218 |
+
) -> dict:
|
| 219 |
+
"""
|
| 220 |
+
Simulate the effect of applying selected interventions.
|
| 221 |
+
Returns before/after comparison.
|
| 222 |
+
"""
|
| 223 |
+
# Current metrics
|
| 224 |
+
di_before = compute_disparate_impact(df, protected_attr)
|
| 225 |
+
eo_before = compute_equal_opportunity_gap(df, protected_attr)
|
| 226 |
+
cal_before = compute_calibration_gap(df, protected_attr)
|
| 227 |
+
|
| 228 |
+
# Simulate the remediation
|
| 229 |
+
df_remediated = df.copy()
|
| 230 |
+
|
| 231 |
+
# Parse which types of interventions are selected
|
| 232 |
+
has_proxy_removal = any("proxy" in iid for iid in intervention_ids)
|
| 233 |
+
has_reweighting = any("reweight" in iid for iid in intervention_ids)
|
| 234 |
+
has_threshold = any("threshold" in iid for iid in intervention_ids)
|
| 235 |
+
has_calibration = any("calibrate" in iid for iid in intervention_ids)
|
| 236 |
+
|
| 237 |
+
# Apply simulated effects
|
| 238 |
+
if has_threshold or has_reweighting:
|
| 239 |
+
# Shift decisions for underprivileged groups
|
| 240 |
+
groups = df_remediated[protected_attr].unique()
|
| 241 |
+
rates = {g: df_remediated.loc[df_remediated[protected_attr] == g, "decision"].mean() for g in groups}
|
| 242 |
+
max_rate = max(rates.values())
|
| 243 |
+
|
| 244 |
+
for g, rate in rates.items():
|
| 245 |
+
if rate < max_rate * 0.90:
|
| 246 |
+
mask = (df_remediated[protected_attr] == g) & (df_remediated["decision"] == 0)
|
| 247 |
+
n_to_flip = int(mask.sum() * 0.3) # flip ~30% of rejections
|
| 248 |
+
if has_threshold:
|
| 249 |
+
n_to_flip = int(mask.sum() * 0.5)
|
| 250 |
+
flip_indices = df_remediated[mask].sample(n=min(n_to_flip, mask.sum()), random_state=42).index
|
| 251 |
+
df_remediated.loc[flip_indices, "decision"] = 1
|
| 252 |
+
|
| 253 |
+
if has_proxy_removal:
|
| 254 |
+
# Simulate removing proxy influence by adding noise to decisions
|
| 255 |
+
# that were affected by proxy variables
|
| 256 |
+
rng = np.random.default_rng(42)
|
| 257 |
+
noise = rng.choice([0, 1], size=len(df_remediated), p=[0.95, 0.05])
|
| 258 |
+
decisions = df_remediated["decision"].values.copy()
|
| 259 |
+
# Only flip some denials to approvals for disadvantaged groups
|
| 260 |
+
groups = df_remediated[protected_attr].unique()
|
| 261 |
+
rates = {g: df_remediated.loc[df_remediated[protected_attr] == g, "decision"].mean() for g in groups}
|
| 262 |
+
max_rate = max(rates.values())
|
| 263 |
+
|
| 264 |
+
for g, rate in rates.items():
|
| 265 |
+
if rate < max_rate * 0.95:
|
| 266 |
+
flip_prob = min(0.15, (max_rate - rate) * 0.5)
|
| 267 |
+
mask = (df_remediated[protected_attr] == g) & (df_remediated["decision"] == 0)
|
| 268 |
+
n_flip = int(mask.sum() * flip_prob)
|
| 269 |
+
if n_flip > 0:
|
| 270 |
+
flip_idx = df_remediated[mask].sample(n=n_flip, random_state=42).index
|
| 271 |
+
df_remediated.loc[flip_idx, "decision"] = 1
|
| 272 |
+
|
| 273 |
+
# Compute after metrics
|
| 274 |
+
di_after = compute_disparate_impact(df_remediated, protected_attr)
|
| 275 |
+
eo_after = compute_equal_opportunity_gap(df_remediated, protected_attr)
|
| 276 |
+
cal_after = compute_calibration_gap(df_remediated, protected_attr)
|
| 277 |
+
|
| 278 |
+
# Build approval rates before/after
|
| 279 |
+
groups = df[protected_attr].unique()
|
| 280 |
+
|
| 281 |
+
def get_approval_rates(data: pd.DataFrame, attr: str) -> list[dict]:
|
| 282 |
+
rates = []
|
| 283 |
+
for g in sorted(data[attr].unique()):
|
| 284 |
+
mask = data[attr] == g
|
| 285 |
+
rates.append({
|
| 286 |
+
"group": str(g),
|
| 287 |
+
"rate": round(float(data.loc[mask, "decision"].mean()), 3),
|
| 288 |
+
"count": int(mask.sum()),
|
| 289 |
+
})
|
| 290 |
+
return rates
|
| 291 |
+
|
| 292 |
+
return {
|
| 293 |
+
"original_disparate_impact": di_before["ratio"],
|
| 294 |
+
"new_disparate_impact": di_after["ratio"],
|
| 295 |
+
"original_eo_gap": eo_before["gap"],
|
| 296 |
+
"new_eo_gap": eo_after["gap"],
|
| 297 |
+
"original_cal_gap": cal_before["gap"],
|
| 298 |
+
"new_cal_gap": cal_after["gap"],
|
| 299 |
+
"approval_rates_before": get_approval_rates(df, protected_attr),
|
| 300 |
+
"approval_rates_after": get_approval_rates(df_remediated, protected_attr),
|
| 301 |
+
"interventions_applied": intervention_ids,
|
| 302 |
+
}
|
backend/services/simulator_service.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FairSight — Simulator Service
|
| 3 |
+
What-if simulation engine: adjust thresholds, reweighting, calibration,
|
| 4 |
+
and feature removal to see real-time impact on fairness metrics.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
import pandas as pd
|
| 10 |
+
|
| 11 |
+
from services.fairness_engine import (
|
| 12 |
+
compute_disparate_impact,
|
| 13 |
+
compute_equal_opportunity_gap,
|
| 14 |
+
compute_calibration_gap,
|
| 15 |
+
)
|
| 16 |
+
from config import PROTECTED_ATTRIBUTES
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def simulate(
|
| 20 |
+
df: pd.DataFrame,
|
| 21 |
+
threshold_adjustment: float = 0.0,
|
| 22 |
+
reweighting_strength: float = 0.0,
|
| 23 |
+
calibration_strength: float = 0.0,
|
| 24 |
+
remove_features: list[str] | None = None,
|
| 25 |
+
protected_attribute: str = "race",
|
| 26 |
+
) -> dict:
|
| 27 |
+
"""
|
| 28 |
+
Run a what-if simulation with the given parameters.
|
| 29 |
+
|
| 30 |
+
Parameters:
|
| 31 |
+
- threshold_adjustment: Shift the decision boundary (negative = more approvals)
|
| 32 |
+
- reweighting_strength: 0-1, how aggressively to uplift underrepresented groups
|
| 33 |
+
- calibration_strength: 0-1, Platt-style calibration adjustment
|
| 34 |
+
- remove_features: list of feature names to exclude (proxy removal simulation)
|
| 35 |
+
- protected_attribute: which attribute to evaluate fairness against
|
| 36 |
+
|
| 37 |
+
Returns before/after metrics and approval rates.
|
| 38 |
+
"""
|
| 39 |
+
if protected_attribute not in df.columns:
|
| 40 |
+
protected_attribute = "race"
|
| 41 |
+
|
| 42 |
+
rng = np.random.default_rng(42)
|
| 43 |
+
df_sim = df.copy()
|
| 44 |
+
|
| 45 |
+
# ── Original metrics ──────────────────────────────────────────────────
|
| 46 |
+
orig_di = compute_disparate_impact(df, protected_attribute)
|
| 47 |
+
orig_eo = compute_equal_opportunity_gap(df, protected_attribute)
|
| 48 |
+
orig_cal = compute_calibration_gap(df, protected_attribute)
|
| 49 |
+
|
| 50 |
+
# ── 1. Threshold adjustment ───────────────────────────────────────────
|
| 51 |
+
if threshold_adjustment != 0.0:
|
| 52 |
+
# Shift the effective threshold on predicted_prob
|
| 53 |
+
# Negative adjustment → lower threshold → more approvals
|
| 54 |
+
new_threshold = 0.50 + threshold_adjustment
|
| 55 |
+
if "predicted_prob" in df_sim.columns:
|
| 56 |
+
df_sim["decision"] = (df_sim["predicted_prob"] >= new_threshold).astype(int)
|
| 57 |
+
|
| 58 |
+
# ── 2. Feature removal (proxy removal) ────────────────────────────────
|
| 59 |
+
if remove_features:
|
| 60 |
+
# Simulate feature removal by reducing the correlation between
|
| 61 |
+
# the feature and the decision. We do this by re-computing decisions
|
| 62 |
+
# without the proxy influence.
|
| 63 |
+
for feature in remove_features:
|
| 64 |
+
if feature in df_sim.columns:
|
| 65 |
+
groups = df_sim[protected_attribute].unique()
|
| 66 |
+
approval_rates = {
|
| 67 |
+
g: df_sim.loc[df_sim[protected_attribute] == g, "decision"].mean()
|
| 68 |
+
for g in groups
|
| 69 |
+
}
|
| 70 |
+
max_rate = max(approval_rates.values())
|
| 71 |
+
|
| 72 |
+
for g, rate in approval_rates.items():
|
| 73 |
+
if rate < max_rate:
|
| 74 |
+
gap = max_rate - rate
|
| 75 |
+
# Remove a portion of the gap proportional to feature removal
|
| 76 |
+
target_improvement = gap * 0.40
|
| 77 |
+
mask_denied = (df_sim[protected_attribute] == g) & (df_sim["decision"] == 0)
|
| 78 |
+
n_flip = int(mask_denied.sum() * target_improvement)
|
| 79 |
+
if n_flip > 0:
|
| 80 |
+
flip_idx = df_sim[mask_denied].sample(
|
| 81 |
+
n=min(n_flip, mask_denied.sum()), random_state=42
|
| 82 |
+
).index
|
| 83 |
+
df_sim.loc[flip_idx, "decision"] = 1
|
| 84 |
+
|
| 85 |
+
# ── 3. Reweighting ────────────────────────────────────────────────────
|
| 86 |
+
if reweighting_strength > 0:
|
| 87 |
+
groups = df_sim[protected_attribute].unique()
|
| 88 |
+
approval_rates = {
|
| 89 |
+
g: df_sim.loc[df_sim[protected_attribute] == g, "decision"].mean()
|
| 90 |
+
for g in groups
|
| 91 |
+
}
|
| 92 |
+
max_rate = max(approval_rates.values())
|
| 93 |
+
|
| 94 |
+
for g, rate in approval_rates.items():
|
| 95 |
+
if rate < max_rate:
|
| 96 |
+
gap = max_rate - rate
|
| 97 |
+
improvement = gap * reweighting_strength * 0.6
|
| 98 |
+
mask_denied = (df_sim[protected_attribute] == g) & (df_sim["decision"] == 0)
|
| 99 |
+
n_flip = int(mask_denied.sum() * improvement)
|
| 100 |
+
if n_flip > 0:
|
| 101 |
+
flip_idx = df_sim[mask_denied].sample(
|
| 102 |
+
n=min(n_flip, mask_denied.sum()), random_state=42
|
| 103 |
+
).index
|
| 104 |
+
df_sim.loc[flip_idx, "decision"] = 1
|
| 105 |
+
|
| 106 |
+
# ── 4. Calibration ────────────────────────────────────────────────────
|
| 107 |
+
if calibration_strength > 0 and "predicted_prob" in df_sim.columns:
|
| 108 |
+
groups = df_sim[protected_attribute].unique()
|
| 109 |
+
|
| 110 |
+
for g in groups:
|
| 111 |
+
mask = df_sim[protected_attribute] == g
|
| 112 |
+
subset = df_sim.loc[mask]
|
| 113 |
+
|
| 114 |
+
# Move predicted probabilities toward actual qualification rates
|
| 115 |
+
actual_rate = subset["qualified"].mean()
|
| 116 |
+
predicted_mean = subset["predicted_prob"].mean()
|
| 117 |
+
adjustment = (actual_rate - predicted_mean) * calibration_strength
|
| 118 |
+
|
| 119 |
+
df_sim.loc[mask, "predicted_prob"] = np.clip(
|
| 120 |
+
df_sim.loc[mask, "predicted_prob"] + adjustment, 0, 1
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
# Re-threshold after calibration
|
| 124 |
+
df_sim["decision"] = (df_sim["predicted_prob"] >= 0.50 + threshold_adjustment).astype(int)
|
| 125 |
+
|
| 126 |
+
# ── Simulated metrics ─────────────────────────────────────────────────
|
| 127 |
+
sim_di = compute_disparate_impact(df_sim, protected_attribute)
|
| 128 |
+
sim_eo = compute_equal_opportunity_gap(df_sim, protected_attribute)
|
| 129 |
+
sim_cal = compute_calibration_gap(df_sim, protected_attribute)
|
| 130 |
+
|
| 131 |
+
# ── Approval rates ────────────────────────────────────────────────────
|
| 132 |
+
def build_rates(data: pd.DataFrame) -> list[dict]:
|
| 133 |
+
rates = []
|
| 134 |
+
for g in sorted(data[protected_attribute].unique()):
|
| 135 |
+
mask = data[protected_attribute] == g
|
| 136 |
+
rates.append({
|
| 137 |
+
"group": str(g),
|
| 138 |
+
"rate": round(float(data.loc[mask, "decision"].mean()), 3),
|
| 139 |
+
"count": int(mask.sum()),
|
| 140 |
+
})
|
| 141 |
+
return rates
|
| 142 |
+
|
| 143 |
+
original_rates = build_rates(df)
|
| 144 |
+
simulated_rates = build_rates(df_sim)
|
| 145 |
+
|
| 146 |
+
# ── Accuracy impact ───────────────────────────────────────────────────
|
| 147 |
+
original_accuracy = (df["decision"] == df["qualified"]).mean()
|
| 148 |
+
simulated_accuracy = (df_sim["decision"] == df_sim["qualified"]).mean()
|
| 149 |
+
accuracy_impact = round(simulated_accuracy - original_accuracy, 4)
|
| 150 |
+
|
| 151 |
+
# ── Overall approval change ───────────────────────────────────────────
|
| 152 |
+
original_approval = df["decision"].mean()
|
| 153 |
+
simulated_approval = df_sim["decision"].mean()
|
| 154 |
+
overall_change = round(simulated_approval - original_approval, 4)
|
| 155 |
+
|
| 156 |
+
return {
|
| 157 |
+
"original_approval_rates": original_rates,
|
| 158 |
+
"simulated_approval_rates": simulated_rates,
|
| 159 |
+
"original_disparate_impact": orig_di["ratio"],
|
| 160 |
+
"simulated_disparate_impact": sim_di["ratio"],
|
| 161 |
+
"original_eo_gap": orig_eo["gap"],
|
| 162 |
+
"simulated_eo_gap": sim_eo["gap"],
|
| 163 |
+
"original_cal_gap": orig_cal["gap"],
|
| 164 |
+
"simulated_cal_gap": sim_cal["gap"],
|
| 165 |
+
"overall_approval_change": overall_change,
|
| 166 |
+
"accuracy_impact": accuracy_impact,
|
| 167 |
+
}
|
cloudbuild.yaml
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
steps:
|
| 2 |
+
- name: node:20
|
| 3 |
+
entrypoint: npm
|
| 4 |
+
args: ["install"]
|
| 5 |
+
dir: frontend
|
| 6 |
+
- name: node:20
|
| 7 |
+
entrypoint: npm
|
| 8 |
+
args: ["run", "build"]
|
| 9 |
+
dir: frontend
|
| 10 |
+
- name: gcr.io/firebase-ci/firebase
|
| 11 |
+
entrypoint: firebase
|
| 12 |
+
args: ["deploy", "--only", "hosting", "--project", "fairlens-494713"]
|
| 13 |
+
- name: gcr.io/cloud-builders/docker
|
| 14 |
+
args: ["build", "-t", "us-central1-docker.pkg.dev/fairlens-494713/fairlens/fairsight-backend", "./backend"]
|
| 15 |
+
- name: gcr.io/cloud-builders/docker
|
| 16 |
+
args: ["push", "us-central1-docker.pkg.dev/fairlens-494713/fairlens/fairsight-backend"]
|
| 17 |
+
- name: gcr.io/google.com/cloudsdktool/cloud-sdk
|
| 18 |
+
entrypoint: gcloud
|
| 19 |
+
args:
|
| 20 |
+
[
|
| 21 |
+
"run",
|
| 22 |
+
"deploy",
|
| 23 |
+
"fairsight-backend",
|
| 24 |
+
"--image",
|
| 25 |
+
"us-central1-docker.pkg.dev/fairlens-494713/fairlens/fairsight-backend",
|
| 26 |
+
"--region",
|
| 27 |
+
"us-central1",
|
| 28 |
+
"--platform",
|
| 29 |
+
"managed",
|
| 30 |
+
"--allow-unauthenticated"
|
| 31 |
+
]
|
| 32 |
+
|
| 33 |
+
images:
|
| 34 |
+
- us-central1-docker.pkg.dev/fairlens-494713/fairlens/fairsight-backend
|
firebase.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"firestore": {
|
| 3 |
+
"rules": "firestore.rules",
|
| 4 |
+
"indexes": "firestore.indexes.json"
|
| 5 |
+
},
|
| 6 |
+
"storage": {
|
| 7 |
+
"rules": "storage.rules"
|
| 8 |
+
},
|
| 9 |
+
"functions": {
|
| 10 |
+
"source": "functions"
|
| 11 |
+
},
|
| 12 |
+
"hosting": {
|
| 13 |
+
"public": "frontend/dist",
|
| 14 |
+
"ignore": [
|
| 15 |
+
"firebase.json",
|
| 16 |
+
"**/.*",
|
| 17 |
+
"**/node_modules/**"
|
| 18 |
+
],
|
| 19 |
+
"rewrites": [
|
| 20 |
+
{
|
| 21 |
+
"source": "**",
|
| 22 |
+
"destination": "/index.html"
|
| 23 |
+
}
|
| 24 |
+
]
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
|
firestore.indexes.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"indexes": [
|
| 3 |
+
{
|
| 4 |
+
"collectionGroup": "analyses",
|
| 5 |
+
"queryScope": "COLLECTION",
|
| 6 |
+
"fields": [
|
| 7 |
+
{ "fieldPath": "orgId", "order": "ASCENDING" },
|
| 8 |
+
{ "fieldPath": "createdAt", "order": "DESCENDING" }
|
| 9 |
+
]
|
| 10 |
+
},
|
| 11 |
+
{
|
| 12 |
+
"collectionGroup": "alerts",
|
| 13 |
+
"queryScope": "COLLECTION",
|
| 14 |
+
"fields": [
|
| 15 |
+
{ "fieldPath": "orgId", "order": "ASCENDING" },
|
| 16 |
+
{ "fieldPath": "severity", "order": "ASCENDING" },
|
| 17 |
+
{ "fieldPath": "createdAt", "order": "DESCENDING" }
|
| 18 |
+
]
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
"collectionGroup": "remediations",
|
| 22 |
+
"queryScope": "COLLECTION",
|
| 23 |
+
"fields": [
|
| 24 |
+
{ "fieldPath": "orgId", "order": "ASCENDING" },
|
| 25 |
+
{ "fieldPath": "analysisId", "order": "ASCENDING" },
|
| 26 |
+
{ "fieldPath": "createdAt", "order": "DESCENDING" }
|
| 27 |
+
]
|
| 28 |
+
}
|
| 29 |
+
],
|
| 30 |
+
"fieldOverrides": []
|
| 31 |
+
}
|
| 32 |
+
|
firestore.rules
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
rules_version = '2';
|
| 2 |
+
service cloud.firestore {
|
| 3 |
+
match /databases/{database}/documents {
|
| 4 |
+
function signedIn() {
|
| 5 |
+
return request.auth != null;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
function isAdmin() {
|
| 9 |
+
return signedIn() && request.auth.token.admin == true;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
function orgMatches(resourceOrgId) {
|
| 13 |
+
return signedIn() && request.auth.token.orgId != null && request.auth.token.orgId == resourceOrgId;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
function canReadOrgData() {
|
| 17 |
+
return isAdmin() || orgMatches(resource.data.orgId);
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
function canWriteOrgData() {
|
| 21 |
+
return isAdmin() || (
|
| 22 |
+
signedIn() &&
|
| 23 |
+
request.resource.data.orgId == request.auth.token.orgId &&
|
| 24 |
+
request.resource.data.createdByUid == request.auth.uid
|
| 25 |
+
);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
match /analyses/{docId} {
|
| 29 |
+
allow read: if canReadOrgData();
|
| 30 |
+
allow create, update: if canWriteOrgData();
|
| 31 |
+
allow delete: if isAdmin();
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
match /alerts/{docId} {
|
| 35 |
+
allow read: if canReadOrgData();
|
| 36 |
+
allow create, update: if canWriteOrgData();
|
| 37 |
+
allow delete: if isAdmin();
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
match /datasets/{docId} {
|
| 41 |
+
allow read: if canReadOrgData();
|
| 42 |
+
allow create, update: if canWriteOrgData();
|
| 43 |
+
allow delete: if isAdmin();
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
match /remediations/{docId} {
|
| 47 |
+
allow read: if canReadOrgData();
|
| 48 |
+
allow create, update: if canWriteOrgData();
|
| 49 |
+
allow delete: if isAdmin();
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
match /auditLogs/{docId} {
|
| 53 |
+
allow read: if isAdmin();
|
| 54 |
+
allow create: if signedIn();
|
| 55 |
+
allow update, delete: if false;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
match /users/{uid} {
|
| 59 |
+
allow read: if signedIn() && (request.auth.uid == uid || isAdmin());
|
| 60 |
+
allow create, update: if signedIn() && request.auth.uid == uid;
|
| 61 |
+
allow delete: if false;
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
|
frontend/.env.example
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
VITE_FIREBASE_API_KEY=
|
| 2 |
+
VITE_FIREBASE_AUTH_DOMAIN=fairlens-494713.firebaseapp.com
|
| 3 |
+
VITE_FIREBASE_PROJECT_ID=fairlens-494713
|
| 4 |
+
VITE_FIREBASE_STORAGE_BUCKET=fairlens-494713.firebasestorage.app
|
| 5 |
+
VITE_FIREBASE_MESSAGING_SENDER_ID=884728849298
|
| 6 |
+
VITE_FIREBASE_APP_ID=
|
| 7 |
+
VITE_FIREBASE_FUNCTIONS_REGION=us-central1
|
| 8 |
+
|
frontend/index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>FairSight</title>
|
| 7 |
+
</head>
|
| 8 |
+
<body>
|
| 9 |
+
<div id="root"></div>
|
| 10 |
+
<script type="module" src="/src/main.jsx"></script>
|
| 11 |
+
</body>
|
| 12 |
+
</html>
|
| 13 |
+
|
frontend/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "fairsight-frontend",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.1.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "vite build",
|
| 9 |
+
"preview": "vite preview"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"firebase": "^11.7.1",
|
| 13 |
+
"papaparse": "^5.5.3",
|
| 14 |
+
"react": "^18.3.1",
|
| 15 |
+
"react-dom": "^18.3.1",
|
| 16 |
+
"react-router-dom": "^6.30.0",
|
| 17 |
+
"recharts": "^2.15.3"
|
| 18 |
+
},
|
| 19 |
+
"devDependencies": {
|
| 20 |
+
"@vitejs/plugin-react": "^4.4.1",
|
| 21 |
+
"vite": "^5.4.19"
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
|
frontend/src/App.jsx
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Navigate, Route, Routes } from "react-router-dom";
|
| 2 |
+
import { useAuth } from "./contexts/AuthContext";
|
| 3 |
+
import AppShell from "./components/AppShell";
|
| 4 |
+
import DashboardPage from "./pages/DashboardPage";
|
| 5 |
+
import MetricsPage from "./pages/MetricsPage";
|
| 6 |
+
import AlertsPage from "./pages/AlertsPage";
|
| 7 |
+
import SimulatorPage from "./pages/SimulatorPage";
|
| 8 |
+
import UploadPage from "./pages/UploadPage";
|
| 9 |
+
import RemediationPage from "./pages/RemediationPage";
|
| 10 |
+
import LoginPage from "./pages/LoginPage";
|
| 11 |
+
|
| 12 |
+
function ProtectedRoute({ children }) {
|
| 13 |
+
const { user, loading } = useAuth();
|
| 14 |
+
|
| 15 |
+
if (loading) {
|
| 16 |
+
return <div className="screen-center">Loading...</div>;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
if (!user) {
|
| 20 |
+
return <Navigate to="/login" replace />;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
return children;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
export default function App() {
|
| 27 |
+
return (
|
| 28 |
+
<Routes>
|
| 29 |
+
<Route path="/login" element={<LoginPage />} />
|
| 30 |
+
<Route
|
| 31 |
+
path="/"
|
| 32 |
+
element={
|
| 33 |
+
<ProtectedRoute>
|
| 34 |
+
<AppShell />
|
| 35 |
+
</ProtectedRoute>
|
| 36 |
+
}
|
| 37 |
+
>
|
| 38 |
+
<Route index element={<DashboardPage />} />
|
| 39 |
+
<Route path="metrics" element={<MetricsPage />} />
|
| 40 |
+
<Route path="alerts" element={<AlertsPage />} />
|
| 41 |
+
<Route path="simulator" element={<SimulatorPage />} />
|
| 42 |
+
<Route path="upload" element={<UploadPage />} />
|
| 43 |
+
<Route path="remediations" element={<RemediationPage />} />
|
| 44 |
+
</Route>
|
| 45 |
+
</Routes>
|
| 46 |
+
);
|
| 47 |
+
}
|
| 48 |
+
|
frontend/src/components/AppShell.jsx
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NavLink, Outlet } from "react-router-dom";
|
| 2 |
+
import { useState } from "react";
|
| 3 |
+
import { useAuth } from "../contexts/AuthContext";
|
| 4 |
+
import { httpsCallable } from "firebase/functions";
|
| 5 |
+
import { functions } from "../lib/firebase";
|
| 6 |
+
import GeminiChat from "./GeminiChat";
|
| 7 |
+
|
| 8 |
+
const navItems = [
|
| 9 |
+
["Dashboard", "/"],
|
| 10 |
+
["Metrics", "/metrics"],
|
| 11 |
+
["Alerts", "/alerts"],
|
| 12 |
+
["Simulator", "/simulator"],
|
| 13 |
+
["Upload", "/upload"],
|
| 14 |
+
["Remediations", "/remediations"]
|
| 15 |
+
];
|
| 16 |
+
|
| 17 |
+
export default function AppShell() {
|
| 18 |
+
const { user, logout } = useAuth();
|
| 19 |
+
const [starting, setStarting] = useState(false);
|
| 20 |
+
|
| 21 |
+
const startServer = async () => {
|
| 22 |
+
setStarting(true);
|
| 23 |
+
try {
|
| 24 |
+
const callable = httpsCallable(functions, "startServer");
|
| 25 |
+
const response = await callable({});
|
| 26 |
+
alert("Server started successfully!");
|
| 27 |
+
} catch (error) {
|
| 28 |
+
alert("Failed to start server: " + error.message);
|
| 29 |
+
} finally {
|
| 30 |
+
setStarting(false);
|
| 31 |
+
}
|
| 32 |
+
};
|
| 33 |
+
|
| 34 |
+
return (
|
| 35 |
+
<div className="app-shell">
|
| 36 |
+
<aside className="sidebar">
|
| 37 |
+
<div>
|
| 38 |
+
<p className="eyebrow">FairSight</p>
|
| 39 |
+
<h1>Bias review workspace</h1>
|
| 40 |
+
</div>
|
| 41 |
+
<nav className="nav-list">
|
| 42 |
+
{navItems.map(([label, path]) => (
|
| 43 |
+
<NavLink
|
| 44 |
+
key={path}
|
| 45 |
+
to={path}
|
| 46 |
+
end={path === "/"}
|
| 47 |
+
className={({ isActive }) => `nav-link ${isActive ? "active" : ""}`}
|
| 48 |
+
>
|
| 49 |
+
{label}
|
| 50 |
+
</NavLink>
|
| 51 |
+
))}
|
| 52 |
+
</nav>
|
| 53 |
+
<div className="user-card">
|
| 54 |
+
<div>{user?.email}</div>
|
| 55 |
+
<button className="secondary-button" onClick={startServer} disabled={starting}>
|
| 56 |
+
{starting ? "Starting..." : "START Server"}
|
| 57 |
+
</button>
|
| 58 |
+
<button className="secondary-button" onClick={logout}>
|
| 59 |
+
Sign out
|
| 60 |
+
</button>
|
| 61 |
+
</div>
|
| 62 |
+
</aside>
|
| 63 |
+
<main className="content">
|
| 64 |
+
<Outlet />
|
| 65 |
+
</main>
|
| 66 |
+
<GeminiChat />
|
| 67 |
+
</div>
|
| 68 |
+
);
|
| 69 |
+
}
|
| 70 |
+
|
frontend/src/components/Charts.jsx
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
Bar,
|
| 3 |
+
BarChart,
|
| 4 |
+
CartesianGrid,
|
| 5 |
+
Line,
|
| 6 |
+
LineChart,
|
| 7 |
+
PolarAngleAxis,
|
| 8 |
+
PolarGrid,
|
| 9 |
+
Radar,
|
| 10 |
+
RadarChart,
|
| 11 |
+
ResponsiveContainer,
|
| 12 |
+
Scatter,
|
| 13 |
+
ScatterChart,
|
| 14 |
+
Tooltip,
|
| 15 |
+
XAxis,
|
| 16 |
+
YAxis
|
| 17 |
+
} from "recharts";
|
| 18 |
+
|
| 19 |
+
export function ApprovalRatesChart({ data }) {
|
| 20 |
+
return (
|
| 21 |
+
<ResponsiveContainer width="100%" height={280}>
|
| 22 |
+
<BarChart data={data}>
|
| 23 |
+
<CartesianGrid strokeDasharray="3 3" />
|
| 24 |
+
<XAxis dataKey="group" />
|
| 25 |
+
<YAxis />
|
| 26 |
+
<Tooltip />
|
| 27 |
+
<Bar dataKey="rate" fill="#2563eb" />
|
| 28 |
+
</BarChart>
|
| 29 |
+
</ResponsiveContainer>
|
| 30 |
+
);
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
export function FairnessRadar({ data }) {
|
| 34 |
+
return (
|
| 35 |
+
<ResponsiveContainer width="100%" height={280}>
|
| 36 |
+
<RadarChart data={data}>
|
| 37 |
+
<PolarGrid />
|
| 38 |
+
<PolarAngleAxis dataKey="metric" />
|
| 39 |
+
<Radar dataKey="score" stroke="#0f766e" fill="#14b8a6" fillOpacity={0.4} />
|
| 40 |
+
<Tooltip />
|
| 41 |
+
</RadarChart>
|
| 42 |
+
</ResponsiveContainer>
|
| 43 |
+
);
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
export function CalibrationScatter({ data }) {
|
| 47 |
+
return (
|
| 48 |
+
<ResponsiveContainer width="100%" height={300}>
|
| 49 |
+
<ScatterChart>
|
| 50 |
+
<CartesianGrid strokeDasharray="3 3" />
|
| 51 |
+
<XAxis dataKey="predicted" name="Predicted" />
|
| 52 |
+
<YAxis dataKey="actual" name="Actual" />
|
| 53 |
+
<Tooltip cursor={{ strokeDasharray: "3 3" }} />
|
| 54 |
+
<Scatter data={data} fill="#7c3aed" />
|
| 55 |
+
</ScatterChart>
|
| 56 |
+
</ResponsiveContainer>
|
| 57 |
+
);
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
export function BeforeAfterLine({ data }) {
|
| 61 |
+
return (
|
| 62 |
+
<ResponsiveContainer width="100%" height={280}>
|
| 63 |
+
<LineChart data={data}>
|
| 64 |
+
<CartesianGrid strokeDasharray="3 3" />
|
| 65 |
+
<XAxis dataKey="group" />
|
| 66 |
+
<YAxis />
|
| 67 |
+
<Tooltip />
|
| 68 |
+
<Line type="monotone" dataKey="before" stroke="#475569" />
|
| 69 |
+
<Line type="monotone" dataKey="after" stroke="#16a34a" />
|
| 70 |
+
</LineChart>
|
| 71 |
+
</ResponsiveContainer>
|
| 72 |
+
);
|
| 73 |
+
}
|
| 74 |
+
|
frontend/src/components/GeminiChat.jsx
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { httpsCallable } from "firebase/functions";
|
| 2 |
+
import { useState } from "react";
|
| 3 |
+
import { functions } from "../lib/firebase";
|
| 4 |
+
|
| 5 |
+
export default function GeminiChat() {
|
| 6 |
+
const [open, setOpen] = useState(false);
|
| 7 |
+
const [question, setQuestion] = useState("");
|
| 8 |
+
const [messages, setMessages] = useState([]);
|
| 9 |
+
const [loading, setLoading] = useState(false);
|
| 10 |
+
|
| 11 |
+
const ask = async () => {
|
| 12 |
+
if (!question.trim()) return;
|
| 13 |
+
setLoading(true);
|
| 14 |
+
const nextQuestion = question;
|
| 15 |
+
setMessages((current) => [...current, { role: "user", text: nextQuestion }]);
|
| 16 |
+
setQuestion("");
|
| 17 |
+
|
| 18 |
+
try {
|
| 19 |
+
const callable = httpsCallable(functions, "geminiExplain");
|
| 20 |
+
const result = await callable({ question: nextQuestion });
|
| 21 |
+
setMessages((current) => [...current, { role: "assistant", text: result.data.answer }]);
|
| 22 |
+
} catch (error) {
|
| 23 |
+
setMessages((current) => [
|
| 24 |
+
...current,
|
| 25 |
+
{ role: "assistant", text: error.message || "Explanation service unavailable." }
|
| 26 |
+
]);
|
| 27 |
+
} finally {
|
| 28 |
+
setLoading(false);
|
| 29 |
+
}
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
return (
|
| 33 |
+
<div className={`chat-widget ${open ? "open" : ""}`}>
|
| 34 |
+
<button className="chat-toggle" onClick={() => setOpen((current) => !current)}>
|
| 35 |
+
Explainability Bot
|
| 36 |
+
</button>
|
| 37 |
+
{open && (
|
| 38 |
+
<div className="chat-panel">
|
| 39 |
+
<div className="chat-messages">
|
| 40 |
+
{messages.map((message, index) => (
|
| 41 |
+
<div key={`${message.role}-${index}`} className={`chat-bubble ${message.role}`}>
|
| 42 |
+
{message.text}
|
| 43 |
+
</div>
|
| 44 |
+
))}
|
| 45 |
+
</div>
|
| 46 |
+
<div className="chat-input-row">
|
| 47 |
+
<input
|
| 48 |
+
value={question}
|
| 49 |
+
onChange={(event) => setQuestion(event.target.value)}
|
| 50 |
+
placeholder="Why is my disparate impact score low?"
|
| 51 |
+
/>
|
| 52 |
+
<button onClick={ask} disabled={loading}>
|
| 53 |
+
Send
|
| 54 |
+
</button>
|
| 55 |
+
</div>
|
| 56 |
+
</div>
|
| 57 |
+
)}
|
| 58 |
+
</div>
|
| 59 |
+
);
|
| 60 |
+
}
|
| 61 |
+
|
frontend/src/contexts/AuthContext.jsx
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
| 2 |
+
import {
|
| 3 |
+
GoogleAuthProvider,
|
| 4 |
+
createUserWithEmailAndPassword,
|
| 5 |
+
onAuthStateChanged,
|
| 6 |
+
signInWithEmailAndPassword,
|
| 7 |
+
signInWithPopup,
|
| 8 |
+
signOut
|
| 9 |
+
} from "firebase/auth";
|
| 10 |
+
import { auth } from "../lib/firebase";
|
| 11 |
+
|
| 12 |
+
const AuthContext = createContext(null);
|
| 13 |
+
const provider = new GoogleAuthProvider();
|
| 14 |
+
|
| 15 |
+
export function AuthProvider({ children }) {
|
| 16 |
+
const [user, setUser] = useState(null);
|
| 17 |
+
const [loading, setLoading] = useState(true);
|
| 18 |
+
|
| 19 |
+
useEffect(() => {
|
| 20 |
+
const unsub = onAuthStateChanged(auth, (nextUser) => {
|
| 21 |
+
setUser(nextUser);
|
| 22 |
+
setLoading(false);
|
| 23 |
+
});
|
| 24 |
+
return unsub;
|
| 25 |
+
}, []);
|
| 26 |
+
|
| 27 |
+
const value = useMemo(
|
| 28 |
+
() => ({
|
| 29 |
+
user,
|
| 30 |
+
loading,
|
| 31 |
+
signInWithGoogle: () => signInWithPopup(auth, provider),
|
| 32 |
+
signInWithEmail: (email, password) => signInWithEmailAndPassword(auth, email, password),
|
| 33 |
+
signUpWithEmail: (email, password) => createUserWithEmailAndPassword(auth, email, password),
|
| 34 |
+
logout: () => signOut(auth)
|
| 35 |
+
}),
|
| 36 |
+
[user, loading]
|
| 37 |
+
);
|
| 38 |
+
|
| 39 |
+
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
export function useAuth() {
|
| 43 |
+
return useContext(AuthContext);
|
| 44 |
+
}
|
| 45 |
+
|
frontend/src/hooks/useOrgCollection.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { collection, onSnapshot, orderBy, query, where } from "firebase/firestore";
|
| 2 |
+
import { useEffect, useState } from "react";
|
| 3 |
+
import { db } from "../lib/firebase";
|
| 4 |
+
import { useAuth } from "../contexts/AuthContext";
|
| 5 |
+
|
| 6 |
+
export function useOrgCollection(collectionName, extraConstraints = []) {
|
| 7 |
+
const { user } = useAuth();
|
| 8 |
+
const [data, setData] = useState([]);
|
| 9 |
+
const [loading, setLoading] = useState(true);
|
| 10 |
+
|
| 11 |
+
useEffect(() => {
|
| 12 |
+
if (!user) {
|
| 13 |
+
setData([]);
|
| 14 |
+
setLoading(false);
|
| 15 |
+
return;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
const orgId = user?.reloadUserInfo?.customAttributes
|
| 19 |
+
? JSON.parse(user.reloadUserInfo.customAttributes).orgId
|
| 20 |
+
: "demo-org";
|
| 21 |
+
|
| 22 |
+
const q = query(
|
| 23 |
+
collection(db, collectionName),
|
| 24 |
+
where("orgId", "==", orgId),
|
| 25 |
+
...extraConstraints,
|
| 26 |
+
orderBy("createdAt", "desc")
|
| 27 |
+
);
|
| 28 |
+
|
| 29 |
+
const unsub = onSnapshot(q, (snapshot) => {
|
| 30 |
+
setData(snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() })));
|
| 31 |
+
setLoading(false);
|
| 32 |
+
});
|
| 33 |
+
|
| 34 |
+
return unsub;
|
| 35 |
+
}, [collectionName, extraConstraints, user]);
|
| 36 |
+
|
| 37 |
+
return { data, loading };
|
| 38 |
+
}
|
| 39 |
+
|
frontend/src/lib/firebase.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { initializeApp } from "firebase/app";
|
| 2 |
+
import { getAuth } from "firebase/auth";
|
| 3 |
+
import { getFirestore } from "firebase/firestore";
|
| 4 |
+
import { getFunctions } from "firebase/functions";
|
| 5 |
+
import { getStorage } from "firebase/storage";
|
| 6 |
+
|
| 7 |
+
const firebaseConfig = {
|
| 8 |
+
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
|
| 9 |
+
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
|
| 10 |
+
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
|
| 11 |
+
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
|
| 12 |
+
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
|
| 13 |
+
appId: import.meta.env.VITE_FIREBASE_APP_ID
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
const app = initializeApp(firebaseConfig);
|
| 17 |
+
|
| 18 |
+
export const auth = getAuth(app);
|
| 19 |
+
export const db = getFirestore(app);
|
| 20 |
+
export const storage = getStorage(app);
|
| 21 |
+
export const functions = getFunctions(
|
| 22 |
+
app,
|
| 23 |
+
import.meta.env.VITE_FIREBASE_FUNCTIONS_REGION || "us-central1"
|
| 24 |
+
);
|
| 25 |
+
|
frontend/src/main.jsx
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import ReactDOM from "react-dom/client";
|
| 3 |
+
import { BrowserRouter } from "react-router-dom";
|
| 4 |
+
import App from "./App";
|
| 5 |
+
import { AuthProvider } from "./contexts/AuthContext";
|
| 6 |
+
import "./styles/app.css";
|
| 7 |
+
|
| 8 |
+
ReactDOM.createRoot(document.getElementById("root")).render(
|
| 9 |
+
<React.StrictMode>
|
| 10 |
+
<BrowserRouter>
|
| 11 |
+
<AuthProvider>
|
| 12 |
+
<App />
|
| 13 |
+
</AuthProvider>
|
| 14 |
+
</BrowserRouter>
|
| 15 |
+
</React.StrictMode>
|
| 16 |
+
);
|
| 17 |
+
|
frontend/src/pages/AlertsPage.jsx
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useOrgCollection } from "../hooks/useOrgCollection";
|
| 2 |
+
|
| 3 |
+
export default function AlertsPage() {
|
| 4 |
+
const { data: alerts, loading } = useOrgCollection("alerts");
|
| 5 |
+
|
| 6 |
+
if (loading) {
|
| 7 |
+
return <div className="panel">Loading alerts...</div>;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
return (
|
| 11 |
+
<div className="page-grid">
|
| 12 |
+
{alerts.map((alert) => (
|
| 13 |
+
<section key={alert.id} className="panel">
|
| 14 |
+
<div className="row-between">
|
| 15 |
+
<h3>{alert.title}</h3>
|
| 16 |
+
<span className={`chip ${alert.severity}`}>{alert.severity}</span>
|
| 17 |
+
</div>
|
| 18 |
+
<p>{alert.description}</p>
|
| 19 |
+
<p className="muted">Affected features: {(alert.affectedFeatures || []).join(", ") || "None"}</p>
|
| 20 |
+
<p className="muted">Recommended action: {alert.recommendation}</p>
|
| 21 |
+
</section>
|
| 22 |
+
))}
|
| 23 |
+
</div>
|
| 24 |
+
);
|
| 25 |
+
}
|
| 26 |
+
|
frontend/src/pages/DashboardPage.jsx
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useMemo } from "react";
|
| 2 |
+
import { ApprovalRatesChart, FairnessRadar } from "../components/Charts";
|
| 3 |
+
import { useOrgCollection } from "../hooks/useOrgCollection";
|
| 4 |
+
|
| 5 |
+
export default function DashboardPage() {
|
| 6 |
+
const { data: analyses, loading } = useOrgCollection("analyses");
|
| 7 |
+
const latest = analyses[0];
|
| 8 |
+
|
| 9 |
+
const radar = useMemo(() => latest?.fairnessScores || [], [latest]);
|
| 10 |
+
|
| 11 |
+
if (loading) {
|
| 12 |
+
return <div className="panel">Loading dashboard...</div>;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
return (
|
| 16 |
+
<div className="page-grid">
|
| 17 |
+
<section className="panel summary-grid">
|
| 18 |
+
<div>
|
| 19 |
+
<p className="eyebrow">Overall fairness score</p>
|
| 20 |
+
<div className="metric-xl">{latest?.overallFairnessScore ?? "--"}</div>
|
| 21 |
+
</div>
|
| 22 |
+
<div>
|
| 23 |
+
<p className="eyebrow">Groups analyzed</p>
|
| 24 |
+
<div className="metric-xl">{latest?.groupsAnalyzed ?? 0}</div>
|
| 25 |
+
</div>
|
| 26 |
+
<div>
|
| 27 |
+
<p className="eyebrow">Flagged features</p>
|
| 28 |
+
<div className="metric-xl">{latest?.flaggedFeatures ?? 0}</div>
|
| 29 |
+
</div>
|
| 30 |
+
</section>
|
| 31 |
+
<section className="panel">
|
| 32 |
+
<h3>Approval rates by group</h3>
|
| 33 |
+
<ApprovalRatesChart data={latest?.approvalRates || []} />
|
| 34 |
+
</section>
|
| 35 |
+
<section className="panel">
|
| 36 |
+
<h3>Fairness profile</h3>
|
| 37 |
+
<FairnessRadar data={radar} />
|
| 38 |
+
</section>
|
| 39 |
+
<section className="panel">
|
| 40 |
+
<h3>Proxy heatmap</h3>
|
| 41 |
+
<div className="heatmap">
|
| 42 |
+
{(latest?.heatmap || []).map((cell) => (
|
| 43 |
+
<div key={`${cell.feature}-${cell.protected_attribute}`} className="heatmap-row">
|
| 44 |
+
<span>{cell.feature}</span>
|
| 45 |
+
<span>{cell.protected_attribute}</span>
|
| 46 |
+
<span>{cell.correlation}</span>
|
| 47 |
+
</div>
|
| 48 |
+
))}
|
| 49 |
+
</div>
|
| 50 |
+
</section>
|
| 51 |
+
</div>
|
| 52 |
+
);
|
| 53 |
+
}
|
| 54 |
+
|
frontend/src/pages/LoginPage.jsx
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from "react";
|
| 2 |
+
import { Navigate } from "react-router-dom";
|
| 3 |
+
import { useAuth } from "../contexts/AuthContext";
|
| 4 |
+
|
| 5 |
+
export default function LoginPage() {
|
| 6 |
+
const { user, signInWithEmail, signInWithGoogle, signUpWithEmail } = useAuth();
|
| 7 |
+
const [email, setEmail] = useState("");
|
| 8 |
+
const [password, setPassword] = useState("");
|
| 9 |
+
const [createMode, setCreateMode] = useState(false);
|
| 10 |
+
|
| 11 |
+
if (user) {
|
| 12 |
+
return <Navigate to="/" replace />;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
const submit = async (event) => {
|
| 16 |
+
event.preventDefault();
|
| 17 |
+
if (createMode) {
|
| 18 |
+
await signUpWithEmail(email, password);
|
| 19 |
+
} else {
|
| 20 |
+
await signInWithEmail(email, password);
|
| 21 |
+
}
|
| 22 |
+
};
|
| 23 |
+
|
| 24 |
+
return (
|
| 25 |
+
<div className="screen-center">
|
| 26 |
+
<form className="panel auth-panel" onSubmit={submit}>
|
| 27 |
+
<p className="eyebrow">FairSight</p>
|
| 28 |
+
<h2>Sign in</h2>
|
| 29 |
+
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" />
|
| 30 |
+
<input
|
| 31 |
+
type="password"
|
| 32 |
+
value={password}
|
| 33 |
+
onChange={(e) => setPassword(e.target.value)}
|
| 34 |
+
placeholder="Password"
|
| 35 |
+
/>
|
| 36 |
+
<button type="submit">{createMode ? "Create account" : "Sign in"}</button>
|
| 37 |
+
<button type="button" className="secondary-button" onClick={() => signInWithGoogle()}>
|
| 38 |
+
Continue with Google
|
| 39 |
+
</button>
|
| 40 |
+
<button
|
| 41 |
+
type="button"
|
| 42 |
+
className="link-button"
|
| 43 |
+
onClick={() => setCreateMode((current) => !current)}
|
| 44 |
+
>
|
| 45 |
+
{createMode ? "Already have an account?" : "Need an account?"}
|
| 46 |
+
</button>
|
| 47 |
+
</form>
|
| 48 |
+
</div>
|
| 49 |
+
);
|
| 50 |
+
}
|
| 51 |
+
|
frontend/src/pages/MetricsPage.jsx
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from "react";
|
| 2 |
+
import { CalibrationScatter } from "../components/Charts";
|
| 3 |
+
import { useOrgCollection } from "../hooks/useOrgCollection";
|
| 4 |
+
|
| 5 |
+
export default function MetricsPage() {
|
| 6 |
+
const { data: analyses } = useOrgCollection("analyses");
|
| 7 |
+
const [attribute, setAttribute] = useState("race");
|
| 8 |
+
const latest = analyses.find((item) => item.protectedAttribute === attribute) || analyses[0];
|
| 9 |
+
|
| 10 |
+
return (
|
| 11 |
+
<div className="page-grid">
|
| 12 |
+
<section className="panel toolbar">
|
| 13 |
+
<h2>Fairness metrics</h2>
|
| 14 |
+
<select value={attribute} onChange={(event) => setAttribute(event.target.value)}>
|
| 15 |
+
<option value="race">Race</option>
|
| 16 |
+
<option value="gender">Gender</option>
|
| 17 |
+
<option value="age">Age</option>
|
| 18 |
+
<option value="disability">Disability</option>
|
| 19 |
+
</select>
|
| 20 |
+
</section>
|
| 21 |
+
<section className="panel metrics-grid">
|
| 22 |
+
<div>
|
| 23 |
+
<p className="eyebrow">Disparate impact</p>
|
| 24 |
+
<div className="metric-lg">{latest?.disparateImpactRatio ?? "--"}</div>
|
| 25 |
+
</div>
|
| 26 |
+
<div>
|
| 27 |
+
<p className="eyebrow">Equal opportunity gap</p>
|
| 28 |
+
<div className="metric-lg">{latest?.equalOpportunityGap ?? "--"}</div>
|
| 29 |
+
</div>
|
| 30 |
+
<div>
|
| 31 |
+
<p className="eyebrow">Calibration gap</p>
|
| 32 |
+
<div className="metric-lg">{latest?.calibrationGap ?? "--"}</div>
|
| 33 |
+
</div>
|
| 34 |
+
</section>
|
| 35 |
+
<section className="panel">
|
| 36 |
+
<h3>Group outcomes</h3>
|
| 37 |
+
<div className="list">
|
| 38 |
+
{(latest?.groupDetails || []).map((item) => (
|
| 39 |
+
<div key={item.group} className="list-row">
|
| 40 |
+
<span>{item.group}</span>
|
| 41 |
+
<span>{item.value}</span>
|
| 42 |
+
<span>{item.sample_size}</span>
|
| 43 |
+
</div>
|
| 44 |
+
))}
|
| 45 |
+
</div>
|
| 46 |
+
</section>
|
| 47 |
+
<section className="panel">
|
| 48 |
+
<h3>Predicted vs actual</h3>
|
| 49 |
+
<CalibrationScatter data={latest?.scatterData || []} />
|
| 50 |
+
</section>
|
| 51 |
+
</div>
|
| 52 |
+
);
|
| 53 |
+
}
|
| 54 |
+
|
frontend/src/pages/RemediationPage.jsx
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useOrgCollection } from "../hooks/useOrgCollection";
|
| 2 |
+
|
| 3 |
+
export default function RemediationPage() {
|
| 4 |
+
const { data: remediations } = useOrgCollection("remediations");
|
| 5 |
+
|
| 6 |
+
return (
|
| 7 |
+
<div className="page-grid">
|
| 8 |
+
{remediations.map((item) => (
|
| 9 |
+
<section key={item.id} className="panel">
|
| 10 |
+
<div className="row-between">
|
| 11 |
+
<h3>{item.title}</h3>
|
| 12 |
+
<span className="chip info">{item.type}</span>
|
| 13 |
+
</div>
|
| 14 |
+
<p>{item.description}</p>
|
| 15 |
+
<p className="muted">Expected DI improvement: {item.expectedDiImprovement}</p>
|
| 16 |
+
</section>
|
| 17 |
+
))}
|
| 18 |
+
</div>
|
| 19 |
+
);
|
| 20 |
+
}
|
| 21 |
+
|
frontend/src/pages/SimulatorPage.jsx
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { httpsCallable } from "firebase/functions";
|
| 2 |
+
import { useState } from "react";
|
| 3 |
+
import { BeforeAfterLine } from "../components/Charts";
|
| 4 |
+
import { functions } from "../lib/firebase";
|
| 5 |
+
|
| 6 |
+
export default function SimulatorPage() {
|
| 7 |
+
const [thresholdAdjustment, setThresholdAdjustment] = useState(0);
|
| 8 |
+
const [reweightingStrength, setReweightingStrength] = useState(0.2);
|
| 9 |
+
const [calibrationStrength, setCalibrationStrength] = useState(0.1);
|
| 10 |
+
const [result, setResult] = useState(null);
|
| 11 |
+
|
| 12 |
+
const runSimulation = async () => {
|
| 13 |
+
const callable = httpsCallable(functions, "runSimulator");
|
| 14 |
+
const response = await callable({
|
| 15 |
+
thresholdAdjustment,
|
| 16 |
+
reweightingStrength,
|
| 17 |
+
calibrationStrength,
|
| 18 |
+
removeFeatures: [],
|
| 19 |
+
protectedAttribute: "race"
|
| 20 |
+
});
|
| 21 |
+
setResult(response.data);
|
| 22 |
+
};
|
| 23 |
+
|
| 24 |
+
const chartData = (result?.originalApprovalRates || []).map((row, index) => ({
|
| 25 |
+
group: row.group,
|
| 26 |
+
before: row.rate,
|
| 27 |
+
after: result?.simulatedApprovalRates?.[index]?.rate ?? row.rate
|
| 28 |
+
}));
|
| 29 |
+
|
| 30 |
+
return (
|
| 31 |
+
<div className="page-grid">
|
| 32 |
+
<section className="panel">
|
| 33 |
+
<h2>What-if simulator</h2>
|
| 34 |
+
<label>
|
| 35 |
+
Threshold
|
| 36 |
+
<input
|
| 37 |
+
type="range"
|
| 38 |
+
min="-0.5"
|
| 39 |
+
max="0.5"
|
| 40 |
+
step="0.05"
|
| 41 |
+
value={thresholdAdjustment}
|
| 42 |
+
onChange={(e) => setThresholdAdjustment(Number(e.target.value))}
|
| 43 |
+
/>
|
| 44 |
+
</label>
|
| 45 |
+
<label>
|
| 46 |
+
Reweighting
|
| 47 |
+
<input
|
| 48 |
+
type="range"
|
| 49 |
+
min="0"
|
| 50 |
+
max="1"
|
| 51 |
+
step="0.05"
|
| 52 |
+
value={reweightingStrength}
|
| 53 |
+
onChange={(e) => setReweightingStrength(Number(e.target.value))}
|
| 54 |
+
/>
|
| 55 |
+
</label>
|
| 56 |
+
<label>
|
| 57 |
+
Calibration
|
| 58 |
+
<input
|
| 59 |
+
type="range"
|
| 60 |
+
min="0"
|
| 61 |
+
max="1"
|
| 62 |
+
step="0.05"
|
| 63 |
+
value={calibrationStrength}
|
| 64 |
+
onChange={(e) => setCalibrationStrength(Number(e.target.value))}
|
| 65 |
+
/>
|
| 66 |
+
</label>
|
| 67 |
+
<button onClick={runSimulation}>Run simulation</button>
|
| 68 |
+
</section>
|
| 69 |
+
<section className="panel">
|
| 70 |
+
<h3>Approval rate change</h3>
|
| 71 |
+
<BeforeAfterLine data={chartData} />
|
| 72 |
+
</section>
|
| 73 |
+
</div>
|
| 74 |
+
);
|
| 75 |
+
}
|
| 76 |
+
|
frontend/src/pages/UploadPage.jsx
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import Papa from "papaparse";
|
| 2 |
+
import { getDownloadURL, ref, uploadBytes } from "firebase/storage";
|
| 3 |
+
import { useState } from "react";
|
| 4 |
+
import { storage } from "../lib/firebase";
|
| 5 |
+
import { useAuth } from "../contexts/AuthContext";
|
| 6 |
+
|
| 7 |
+
export default function UploadPage() {
|
| 8 |
+
const { user } = useAuth();
|
| 9 |
+
const [preview, setPreview] = useState([]);
|
| 10 |
+
const [columns, setColumns] = useState([]);
|
| 11 |
+
const [status, setStatus] = useState("");
|
| 12 |
+
|
| 13 |
+
const handleFile = async (file) => {
|
| 14 |
+
setStatus("Uploading...");
|
| 15 |
+
|
| 16 |
+
Papa.parse(file, {
|
| 17 |
+
header: true,
|
| 18 |
+
preview: 5,
|
| 19 |
+
complete: async (results) => {
|
| 20 |
+
setPreview(results.data);
|
| 21 |
+
setColumns(results.meta.fields || []);
|
| 22 |
+
}
|
| 23 |
+
});
|
| 24 |
+
|
| 25 |
+
const orgId = "demo-org";
|
| 26 |
+
const objectRef = ref(storage, `uploads/${orgId}/${Date.now()}-${file.name}`);
|
| 27 |
+
await uploadBytes(objectRef, file, {
|
| 28 |
+
contentType: file.type || "text/csv",
|
| 29 |
+
customMetadata: {
|
| 30 |
+
uploadedByUid: user?.uid || "",
|
| 31 |
+
orgId
|
| 32 |
+
}
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
await getDownloadURL(objectRef);
|
| 36 |
+
setStatus("Upload complete. Analysis trigger queued.");
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
return (
|
| 40 |
+
<div className="page-grid">
|
| 41 |
+
<section className="panel">
|
| 42 |
+
<h2>Dataset upload</h2>
|
| 43 |
+
<input type="file" accept=".csv" onChange={(e) => handleFile(e.target.files[0])} />
|
| 44 |
+
<p className="muted">{status}</p>
|
| 45 |
+
<div className="list">
|
| 46 |
+
{columns.map((column) => (
|
| 47 |
+
<div key={column} className="list-row">
|
| 48 |
+
<span>{column}</span>
|
| 49 |
+
<span>
|
| 50 |
+
{["race", "gender", "age", "disability"].includes(column) ? "Protected attribute" : "Feature"}
|
| 51 |
+
</span>
|
| 52 |
+
</div>
|
| 53 |
+
))}
|
| 54 |
+
</div>
|
| 55 |
+
</section>
|
| 56 |
+
<section className="panel">
|
| 57 |
+
<h3>Preview</h3>
|
| 58 |
+
<pre className="code-block">{JSON.stringify(preview, null, 2)}</pre>
|
| 59 |
+
</section>
|
| 60 |
+
</div>
|
| 61 |
+
);
|
| 62 |
+
}
|
| 63 |
+
|
frontend/src/styles/app.css
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
color-scheme: light;
|
| 3 |
+
font-family: Inter, Arial, sans-serif;
|
| 4 |
+
color: #0f172a;
|
| 5 |
+
background: #f8fafc;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
* {
|
| 9 |
+
box-sizing: border-box;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
body {
|
| 13 |
+
margin: 0;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
button,
|
| 17 |
+
input,
|
| 18 |
+
select {
|
| 19 |
+
font: inherit;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
button {
|
| 23 |
+
border: 0;
|
| 24 |
+
border-radius: 8px;
|
| 25 |
+
background: #2563eb;
|
| 26 |
+
color: white;
|
| 27 |
+
padding: 10px 14px;
|
| 28 |
+
cursor: pointer;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
input,
|
| 32 |
+
select {
|
| 33 |
+
width: 100%;
|
| 34 |
+
border: 1px solid #cbd5e1;
|
| 35 |
+
border-radius: 8px;
|
| 36 |
+
padding: 10px 12px;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
.secondary-button {
|
| 40 |
+
background: #e2e8f0;
|
| 41 |
+
color: #0f172a;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
.link-button {
|
| 45 |
+
background: transparent;
|
| 46 |
+
color: #2563eb;
|
| 47 |
+
padding: 0;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
.app-shell {
|
| 51 |
+
display: grid;
|
| 52 |
+
grid-template-columns: 260px 1fr;
|
| 53 |
+
min-height: 100vh;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
.sidebar {
|
| 57 |
+
display: flex;
|
| 58 |
+
flex-direction: column;
|
| 59 |
+
justify-content: space-between;
|
| 60 |
+
padding: 24px;
|
| 61 |
+
background: #0f172a;
|
| 62 |
+
color: white;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
.nav-list {
|
| 66 |
+
display: grid;
|
| 67 |
+
gap: 8px;
|
| 68 |
+
margin-top: 24px;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
.nav-link {
|
| 72 |
+
color: #cbd5e1;
|
| 73 |
+
text-decoration: none;
|
| 74 |
+
padding: 10px 12px;
|
| 75 |
+
border-radius: 8px;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
.nav-link.active {
|
| 79 |
+
background: #1d4ed8;
|
| 80 |
+
color: white;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
.content {
|
| 84 |
+
padding: 24px;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
.page-grid {
|
| 88 |
+
display: grid;
|
| 89 |
+
gap: 20px;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
.panel {
|
| 93 |
+
background: white;
|
| 94 |
+
border: 1px solid #e2e8f0;
|
| 95 |
+
border-radius: 8px;
|
| 96 |
+
padding: 20px;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
.summary-grid,
|
| 100 |
+
.metrics-grid {
|
| 101 |
+
display: grid;
|
| 102 |
+
gap: 16px;
|
| 103 |
+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
.toolbar,
|
| 107 |
+
.row-between,
|
| 108 |
+
.list-row,
|
| 109 |
+
.chat-input-row {
|
| 110 |
+
display: flex;
|
| 111 |
+
align-items: center;
|
| 112 |
+
justify-content: space-between;
|
| 113 |
+
gap: 12px;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
.list,
|
| 117 |
+
.heatmap {
|
| 118 |
+
display: grid;
|
| 119 |
+
gap: 10px;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
.eyebrow,
|
| 123 |
+
.muted {
|
| 124 |
+
color: #64748b;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
.metric-xl {
|
| 128 |
+
font-size: 44px;
|
| 129 |
+
font-weight: 700;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
.metric-lg {
|
| 133 |
+
font-size: 28px;
|
| 134 |
+
font-weight: 700;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
.screen-center {
|
| 138 |
+
min-height: 100vh;
|
| 139 |
+
display: grid;
|
| 140 |
+
place-items: center;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
.auth-panel {
|
| 144 |
+
width: min(420px, calc(100vw - 32px));
|
| 145 |
+
display: grid;
|
| 146 |
+
gap: 12px;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
.user-card {
|
| 150 |
+
display: grid;
|
| 151 |
+
gap: 12px;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
.chip {
|
| 155 |
+
padding: 4px 10px;
|
| 156 |
+
border-radius: 999px;
|
| 157 |
+
font-size: 12px;
|
| 158 |
+
text-transform: capitalize;
|
| 159 |
+
background: #e2e8f0;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
.chip.critical {
|
| 163 |
+
background: #fee2e2;
|
| 164 |
+
color: #b91c1c;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
.chip.warning {
|
| 168 |
+
background: #fef3c7;
|
| 169 |
+
color: #b45309;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
.chip.info {
|
| 173 |
+
background: #dbeafe;
|
| 174 |
+
color: #1d4ed8;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
.code-block {
|
| 178 |
+
overflow: auto;
|
| 179 |
+
max-height: 300px;
|
| 180 |
+
background: #0f172a;
|
| 181 |
+
color: #e2e8f0;
|
| 182 |
+
border-radius: 8px;
|
| 183 |
+
padding: 12px;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
.chat-widget {
|
| 187 |
+
position: fixed;
|
| 188 |
+
right: 20px;
|
| 189 |
+
bottom: 20px;
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
.chat-panel {
|
| 193 |
+
width: 320px;
|
| 194 |
+
background: white;
|
| 195 |
+
border: 1px solid #cbd5e1;
|
| 196 |
+
border-radius: 8px;
|
| 197 |
+
padding: 12px;
|
| 198 |
+
margin-top: 10px;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
.chat-messages {
|
| 202 |
+
display: grid;
|
| 203 |
+
gap: 8px;
|
| 204 |
+
max-height: 260px;
|
| 205 |
+
overflow: auto;
|
| 206 |
+
margin-bottom: 12px;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
.chat-bubble {
|
| 210 |
+
padding: 10px;
|
| 211 |
+
border-radius: 8px;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
.chat-bubble.user {
|
| 215 |
+
background: #dbeafe;
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
.chat-bubble.assistant {
|
| 219 |
+
background: #f1f5f9;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
@media (max-width: 900px) {
|
| 223 |
+
.app-shell {
|
| 224 |
+
grid-template-columns: 1fr;
|
| 225 |
+
}
|
| 226 |
+
}
|
| 227 |
+
|
frontend/vite.config.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from "vite";
|
| 2 |
+
import react from "@vitejs/plugin-react";
|
| 3 |
+
|
| 4 |
+
export default defineConfig({
|
| 5 |
+
plugins: [react()],
|
| 6 |
+
server: {
|
| 7 |
+
port: 5173
|
| 8 |
+
}
|
| 9 |
+
});
|
| 10 |
+
|
functions/.env.example
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FAIRNESS_BACKEND_URL=
|
| 2 |
+
GEMINI_API_KEY=
|
| 3 |
+
VERTEX_AI_ENDPOINT=
|
| 4 |
+
GOOGLE_CLOUD_PROJECT=fairlens-494713
|
| 5 |
+
|