{
 "nbformat": 4,
 "nbformat_minor": 5,
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "version": "3.x"
  },
  "colab": {
   "provenance": []
  }
 },
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Lab 10 - Bias Detection in Models ⚖️🔎\n",
    "### CIS 350: AI for Cybersecurity - Smart Defense for the Digital Business\n",
    "\n",
    "**Worth:** 50 points  ·  **Estimated time:** ~60 minutes  ·  **Submit:** this `.ipynb` in Canvas\n",
    "**Required software:** Google Colab (or Jupyter) with `scikit-learn`, `numpy`, `matplotlib` (preinstalled in Colab).\n",
    "**Prerequisites:** Weeks 2-4 (features, train/test, confusion matrix) and Week 12 (fairness & bias).\n",
    "\n",
    "## The scenario\n",
    "A security team uses an ML model to **flag 'risky' accounts** for review. The *true* risk depends only on account **activity** - not on the customer's group. But the model was trained on **biased historical labels** (analysts over-flagged **Group B** in the past) and it has a **proxy feature** that leaks group membership. Your job: **detect the bias** by measuring the model per group, then try a **mitigation**.\n",
    "\n",
    "## Learning objectives\n",
    "By the end you can: (1) compute per-group fairness metrics (selection rate, FPR, TPR); (2) show that high **overall** accuracy can hide per-group harm; (3) visualize a disparity; (4) apply a simple mitigation and re-measure the gap; (5) write a short fairness finding.\n",
    "\n",
    "Run cells top to bottom with **Shift + Enter** and fill every **`TODO`** blank (`____`).\n",
    "\n",
    "> **Academic integrity:** individual or pair work. **AI tools are not permitted** on labs."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Part 1 · Build the data and train the 'risk flagger'\n",
    "The model trains on the **biased historical labels** (`yhist_tr`) using **all** features (including the proxy) - just like a team that trusts its past decisions."
   ]
  },
  {
   "cell_type": "code",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "import warnings\n",
    "warnings.filterwarnings(\"ignore\")   # keep output clean\n",
    "import numpy as np\n",
    "import matplotlib.pyplot as plt\n",
    "from sklearn.linear_model import LogisticRegression\n",
    "from sklearn.model_selection import train_test_split\n",
    "from sklearn.metrics import confusion_matrix, accuracy_score\n",
    "\n",
    "def make_data(seed=42, n=4000):\n",
    "    \"\"\"An account-risk 'flagging' dataset (same as the Week 12 lecture).\"\"\"\n",
    "    rng = np.random.default_rng(seed)\n",
    "    group = rng.integers(0, 2, n)                 # 0 = Group A, 1 = Group B\n",
    "    activity = rng.normal(0.0, 1.0, n)            # legitimate risk signal (same for both groups)\n",
    "    # FAIR ground truth: real risk depends ONLY on activity, not on the group\n",
    "    p_true = 1.0 / (1.0 + np.exp(-(1.6 * activity - 0.4)))\n",
    "    y_true = (rng.random(n) < p_true).astype(int)\n",
    "    # PROXY feature that leaks group membership (like a zip code) - NOT a real cause of risk\n",
    "    proxy = 0.9 * group + rng.normal(0.0, 0.5, n)\n",
    "    # HISTORICAL (biased) labels: analysts over-flagged Group B in the past\n",
    "    y_hist = y_true.copy()\n",
    "    flip = (group == 1) & (y_true == 0) & (rng.random(n) < 0.30)\n",
    "    y_hist[flip] = 1\n",
    "    X = np.column_stack([activity, proxy])        # feature 0 = activity, feature 1 = proxy\n",
    "    return X, y_true, y_hist, group\n",
    "\n",
    "X, y_true, y_hist, group = make_data()\n",
    "# 40% test split, keeping the group balance the same in train and test\n",
    "Xtr, Xte, ytrue_tr, ytrue_te, yhist_tr, yhist_te, g_tr, g_te = train_test_split(\n",
    "    X, y_true, y_hist, group, test_size=0.4, random_state=0, stratify=group)\n",
    "\n",
    "biased = LogisticRegression(max_iter=1000).fit(Xtr, yhist_tr)\n",
    "pred = biased.predict(Xte)\n",
    "# TODO 1: overall accuracy vs the TRUE labels on the test set.\n",
    "acc = accuracy_score(____, pred)\n",
    "print(\"overall accuracy:\", round(acc, 3))   # looks fine... but is it fair?"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Part 2 · A helper to measure the model BY GROUP\n",
    "Overall accuracy hides per-group harm. This helper returns each group's **selection rate** (how often it's flagged), **FPR** (innocent people wrongly flagged), and **TPR** (real cases caught)."
   ]
  },
  {
   "cell_type": "code",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "def group_metrics(y_true, y_pred, group):\n",
    "    \"\"\"Per-group selection rate, false-positive rate (FPR), and true-positive rate (TPR).\"\"\"\n",
    "    results = {}\n",
    "    for g, name in [(0, \"A\"), (1, \"B\")]:\n",
    "        m = (group == g)\n",
    "        yt, yp = y_true[m], y_pred[m]\n",
    "        tn, fp, fn, tp = confusion_matrix(yt, yp, labels=[0, 1]).ravel()\n",
    "        results[name] = {\n",
    "            \"selection_rate\": yp.mean(),                       # how often this group is flagged\n",
    "            \"FPR\": fp / (fp + tn) if (fp + tn) else 0.0,        # innocent people wrongly flagged\n",
    "            \"TPR\": tp / (tp + fn) if (tp + fn) else 0.0,        # real risky cases caught\n",
    "        }\n",
    "    return results\n",
    "\n",
    "m = group_metrics(ytrue_te, pred, g_te)\n",
    "for grp in [\"A\", \"B\"]:\n",
    "    r = m[grp]\n",
    "    print(\"Group\", grp, \" selection\", round(r[\"selection_rate\"], 3),\n",
    "          \" FPR\", round(r[\"FPR\"], 3), \" TPR\", round(r[\"TPR\"], 3))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Part 3 · Measure the disparity\n",
    "Fairness gaps are just differences between the groups' rates."
   ]
  },
  {
   "cell_type": "code",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "# TODO 2: the false-positive-rate GAP between the two groups (use abs()).\n",
    "fpr_gap = abs(m[\"B\"][\"FPR\"] - m[\"A\"][\"____\"])\n",
    "sel_gap = abs(m[\"B\"][\"selection_rate\"] - m[\"A\"][\"selection_rate\"])\n",
    "print(\"selection-rate gap:\", round(sel_gap, 3))\n",
    "print(\"false-positive-rate gap:\", round(fpr_gap, 3))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Part 4 · Visualize the bias\n",
    "A picture makes the disparity obvious to non-technical stakeholders."
   ]
  },
  {
   "cell_type": "code",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "labels = [\"selection\", \"FPR\", \"TPR\"]\n",
    "A = [m[\"A\"][\"selection_rate\"], m[\"A\"][\"FPR\"], m[\"A\"][\"TPR\"]]\n",
    "B = [m[\"B\"][\"selection_rate\"], m[\"B\"][\"FPR\"], m[\"B\"][\"TPR\"]]\n",
    "x = np.arange(3); w = 0.35\n",
    "plt.bar(x - w/2, A, w, label=\"Group A\")\n",
    "plt.bar(x + w/2, B, w, label=\"Group B\")\n",
    "plt.xticks(x, labels); plt.ylabel(\"rate\"); plt.ylim(0, 1)\n",
    "plt.title(\"Biased model: rates by group\"); plt.legend(); plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Part 5 · Mitigation - fix the data, drop the proxy\n",
    "Two changes: train on the **fair** labels (`ytrue_tr`) and use **only the activity feature** (column 0), dropping the group-leaking proxy."
   ]
  },
  {
   "cell_type": "code",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "# TODO 3: fit on fair labels using ONLY feature column 0 (activity).\n",
    "#         Xtr[:, [0]] keeps just the first column as a 2-D array.\n",
    "fair = LogisticRegression(max_iter=1000).fit(Xtr[:, [0]], ____)\n",
    "pred_fair = fair.predict(Xte[:, [0]])\n",
    "mf = group_metrics(ytrue_te, pred_fair, g_te)\n",
    "acc_fair = accuracy_score(ytrue_te, pred_fair)\n",
    "fpr_gap_fair = abs(mf[\"B\"][\"FPR\"] - mf[\"A\"][\"FPR\"])\n",
    "print(\"mitigated accuracy:\", round(acc_fair, 3))\n",
    "print(\"mitigated FPR gap:\", round(fpr_gap_fair, 3))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Part 6 · Before vs after\n",
    "Compare the false-positive-rate gap before and after mitigation."
   ]
  },
  {
   "cell_type": "code",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "# TODO 4: print the FPR gap before (fpr_gap) and after (fpr_gap_fair).\n",
    "print(\"FPR gap  before:\", round(____, 3))\n",
    "print(\"FPR gap  after: \", round(fpr_gap_fair, 3))\n",
    "print(\"accuracy before/after:\", round(acc, 3), \"/\", round(acc_fair, 3))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Part 7 · Reflection questions\n",
    "Double-click and replace each *your answer here*. This is where most of the credit is."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**Your answers**\n",
    "\n",
    "1. **What was the model's OVERALL accuracy, and why is that not enough to trust it?** *your answer here*\n",
    "2. **Which group was worse off, on which metric, and by how much (give the numbers)?** *your answer here*\n",
    "3. **Describe the real-world harm to a person in the worse-off group.** *your answer here*\n",
    "4. **What did the mitigation change, and what happened to the FPR gap and the accuracy?** *your answer here*\n",
    "5. **Name one fairness definition from lecture and one governance step (e.g., a model-card line or an audit question) you would require before deploying this model.** *your answer here*"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## ✅ Before you submit\n",
    "- [ ] **Runtime -> Run all** with no errors.  - [ ] All `TODO` blanks filled.\n",
    "- [ ] All 5 reflection questions answered.  - [ ] **File -> Download -> Download .ipynb**, upload to Canvas.\n",
    "\n",
    "## Troubleshooting\n",
    "- `NameError` -> you skipped a cell; run from the top with **Run all**.\n",
    "- All rates look identical across groups -> check you passed `g_te` (test-set groups), not `group`.\n",
    "- Numbers slightly different -> tiny variation across scikit-learn versions is fine (±0.01).\n",
    "\n",
    "## Extension (optional, ungraded)\n",
    "Try a different mitigation: keep both features but set a **group-aware threshold** so each group's selection rate matches. Does the FPR gap shrink? What did you trade away?"
   ]
  }
 ]
}