{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Is This Water Safe to Drink?\n",
    "\n",
    "safe_water.py\n",
    "\n",
    "Capstone project: predicting, from a quick field-kit test, whether a\n",
    "drinking-water source will fail the laboratory's safety test - so unsafe\n",
    "sources can be flagged the same day instead of two days later.\n",
    "\n",
    "CBSE Class XII, Artificial Intelligence (843), Part C - Capstone Project.\n",
    "SDG 6 (Clean Water and Sanitation) and SDG 3 (Good Health and Well-being).\n",
    "\n",
    "Data Science Methodology, 10 steps. CLASSIFICATION (Safe / Unsafe). The\n",
    "field data has gaps, so the project compares four ways of handling\n",
    "missing values - the Unit 1 topic - with stratified 5-fold cross\n",
    "validation, before training the final Random Forest.\n",
    "\n",
    "Libraries: pandas, numpy, matplotlib, scikit-learn, joblib."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Step 1 - Business Understanding"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(\"=\" * 64)\n",
    "print(\"IS THIS WATER SAFE TO DRINK?\")\n",
    "print(\"=\" * 64)\n",
    "print(\"Problem : the lab's verdict takes two days; people keep drinking meanwhile\")\n",
    "print(\"Goal    : predict the lab verdict from field-kit readings, the same day\")\n",
    "print()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Step 2 - Analytic Approach"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(\"Analytic approach : predictive - classification (Safe or Unsafe)\")\n",
    "print()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Step 3 - Data Requirements"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import pandas as pd\n",
    "import numpy as np\n",
    "import matplotlib.pyplot as plt\n",
    "from sklearn.model_selection import train_test_split, StratifiedKFold, cross_val_score\n",
    "from sklearn.pipeline import make_pipeline\n",
    "from sklearn.impute import SimpleImputer\n",
    "from sklearn.ensemble import RandomForestClassifier\n",
    "from sklearn.metrics import confusion_matrix, accuracy_score, precision_score, recall_score, f1_score\n",
    "import joblib\n",
    "\n",
    "pd.set_option(\"display.width\", 140)\n",
    "pd.set_option(\"display.max_columns\", 20)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Step 4 - Data Collection"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "data = pd.read_csv(\"water_samples.csv\")\n",
    "print(\"Samples collected :\", len(data))\n",
    "print(data.head(6))\n",
    "print()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Step 5 - Data Understanding"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(\"--- Step 5: Data Understanding ---\")\n",
    "missing = data.isnull().sum()\n",
    "print(\"Missing values in each column:\")\n",
    "print(missing.to_string())\n",
    "print()\n",
    "print(\"Lab verdicts:\")\n",
    "print(data[\"verdict\"].value_counts().to_string())\n",
    "print(\"Share unsafe :\", round((data[\"verdict\"] == \"Unsafe\").mean() * 100, 1), \"%\")\n",
    "print()\n",
    "print(\"Share unsafe by kind of source (%):\")\n",
    "by_source = ((data[\"verdict\"] == \"Unsafe\").groupby(data[\"source\"]).mean() * 100).sort_values()\n",
    "print(by_source.round(1).to_string())\n",
    "print()\n",
    "\n",
    "# Chart 1: how much is missing in each column.\n",
    "m = (missing[missing > 0] / len(data) * 100).sort_values()\n",
    "plt.figure(figsize=(7, 3.8))\n",
    "plt.barh(m.index, m.values, color=\"#f28e2b\")\n",
    "plt.title(\"Missing values in the field data\")\n",
    "plt.xlabel(\"% of samples with no reading\")\n",
    "plt.tight_layout()\n",
    "plt.savefig(\"chart1_missing.png\")\n",
    "plt.close()\n",
    "\n",
    "# Chart 2: unsafe share by kind of source.\n",
    "plt.figure(figsize=(7, 4))\n",
    "plt.barh(by_source.index, by_source.values, color=\"#4e9fd1\")\n",
    "plt.title(\"Share of samples the lab found unsafe\")\n",
    "plt.xlabel(\"% unsafe\")\n",
    "plt.tight_layout()\n",
    "plt.savefig(\"chart2_by_source.png\")\n",
    "plt.close()\n",
    "\n",
    "# Chart 3: turbidity for safe and unsafe samples (log scale).\n",
    "turb = pd.to_numeric(data[\"turbidity_ntu\"])\n",
    "plt.figure(figsize=(7, 4.2))\n",
    "plt.boxplot([turb[data[\"verdict\"] == \"Safe\"].dropna(), turb[data[\"verdict\"] == \"Unsafe\"].dropna()],\n",
    "            labels=[\"Safe\", \"Unsafe\"])\n",
    "plt.yscale(\"log\")\n",
    "plt.axhline(5, color=\"#e15759\", linestyle=\"--\", label=\"IS 10500 limit: 5 NTU\")\n",
    "plt.title(\"Turbidity of safe and unsafe samples\")\n",
    "plt.ylabel(\"Turbidity (NTU, log scale)\")\n",
    "plt.legend()\n",
    "plt.tight_layout()\n",
    "plt.savefig(\"chart3_turbidity.png\")\n",
    "plt.close()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Step 6 - Data Preparation"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(\"--- Step 6: Data Preparation ---\")\n",
    "before = len(data)\n",
    "data = data.drop_duplicates()\n",
    "print(\"Duplicate samples removed :\", before - len(data))\n",
    "for col in [\"fluoride_mg_l\", \"nitrate_mg_l\", \"turbidity_ntu\"]:\n",
    "    data[col] = pd.to_numeric(data[col])\n",
    "data[\"unsafe\"] = (data[\"verdict\"] == \"Unsafe\").astype(int)\n",
    "data = pd.get_dummies(data, columns=[\"source\"], dtype=int)\n",
    "features = [\"ph\", \"tds_mg_l\", \"turbidity_ntu\", \"nitrate_mg_l\", \"fluoride_mg_l\", \"chloride_mg_l\",\n",
    "            \"residual_chlorine_mg_l\", \"source_Hand pump\", \"source_Open well\", \"source_Piped supply\",\n",
    "            \"source_Pond\", \"source_Tube well\"]\n",
    "X = data[features]\n",
    "y = data[\"unsafe\"]\n",
    "\n",
    "# Four ways of handling the missing values, each judged by stratified\n",
    "# 5-fold cross validation. The filling happens INSIDE each pipeline, so it\n",
    "# is learned from the training folds only and never sees the test fold.\n",
    "folds = StratifiedKFold(n_splits=5, shuffle=True, random_state=2)\n",
    "forest = RandomForestClassifier(n_estimators=300, min_samples_leaf=2, random_state=0)\n",
    "complete = data.dropna(subset=features)\n",
    "strategies = [\n",
    "    [\"A. Drop rows with a gap\", None],\n",
    "    [\"B. Fill with the mean\", make_pipeline(SimpleImputer(strategy=\"mean\"), forest)],\n",
    "    [\"C. Fill with the median\", make_pipeline(SimpleImputer(strategy=\"median\"), forest)],\n",
    "    [\"D. Median + a 'was missing' column\", make_pipeline(SimpleImputer(strategy=\"median\", add_indicator=True), forest)],\n",
    "]\n",
    "print(\"Rows left if every row with a gap is dropped :\", len(complete), \"of\", len(data))\n",
    "recalls = []\n",
    "for name, model in strategies:\n",
    "    if model is None:\n",
    "        r = cross_val_score(forest, complete[features], complete[\"unsafe\"], cv=folds, scoring=\"recall\")\n",
    "        f = cross_val_score(forest, complete[features], complete[\"unsafe\"], cv=folds, scoring=\"f1\")\n",
    "    else:\n",
    "        r = cross_val_score(model, X, y, cv=folds, scoring=\"recall\")\n",
    "        f = cross_val_score(model, X, y, cv=folds, scoring=\"f1\")\n",
    "    recalls.append([name, r.mean(), f.mean()])\n",
    "    print(name.ljust(36), \"CV recall\", round(r.mean(), 3), \"  CV F1\", round(f.mean(), 3))\n",
    "print()\n",
    "\n",
    "# Chart 4: the four strategies compared.\n",
    "x = np.arange(4)\n",
    "plt.figure(figsize=(8, 4.3))\n",
    "plt.bar(x - 0.2, [r[1] for r in recalls], width=0.4, label=\"Recall (unsafe found)\", color=\"#e15759\")\n",
    "plt.bar(x + 0.2, [r[2] for r in recalls], width=0.4, label=\"F1 score\", color=\"#4e79a7\")\n",
    "plt.xticks(x, [\"A. Drop\", \"B. Mean\", \"C. Median\", \"D. Median +\\nindicator\"])\n",
    "plt.ylim(0.5, 1)\n",
    "plt.ylabel(\"Mean over 5 folds\")\n",
    "plt.title(\"Four ways of handling missing values\")\n",
    "plt.legend()\n",
    "plt.tight_layout()\n",
    "plt.savefig(\"chart4_strategies.png\")\n",
    "plt.close()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Step 7 - Modelling"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(\"--- Step 7: Modelling ---\")\n",
    "X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, stratify=y, random_state=5)\n",
    "# Strategy A scored best, but only on the 797 complete rows, and a deployed\n",
    "# model cannot refuse every sample with a missing test. B, C and D tied, and\n",
    "# the indicator columns added nothing, so the simplest - C, the median - is used.\n",
    "model = make_pipeline(SimpleImputer(strategy=\"median\"),\n",
    "                      RandomForestClassifier(n_estimators=300, min_samples_leaf=2, random_state=0))\n",
    "model.fit(X_train, y_train)\n",
    "print(\"Strategy used          : C (fill with the median)\")\n",
    "print(\"Training samples       :\", len(X_train), \" Testing samples :\", len(X_test))\n",
    "print()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Step 8 - Evaluation"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(\"--- Step 8: Evaluation ---\")\n",
    "pred = model.predict(X_test)\n",
    "tn, fp, fn, tp = confusion_matrix(y_test, pred).ravel()\n",
    "print(\"TP, FN, FP, TN :\", tp, fn, fp, tn)\n",
    "print(\"Accuracy       :\", round(accuracy_score(y_test, pred), 3))\n",
    "print(\"Precision      :\", round(precision_score(y_test, pred), 3))\n",
    "print(\"Recall         :\", round(recall_score(y_test, pred), 3))\n",
    "print(\"F1 score       :\", round(f1_score(y_test, pred), 3))\n",
    "print()\n",
    "cv_recall = cross_val_score(model, X, y, cv=folds, scoring=\"recall\")\n",
    "print(\"Recall in each of the 5 folds :\", np.round(cv_recall, 3))\n",
    "print()\n",
    "\n",
    "# A missed unsafe source is far worse than an extra lab test, so the\n",
    "# threshold is lowered from 0.5 to 0.3.\n",
    "threshold = 0.3\n",
    "prob_test = model.predict_proba(X_test)[:, 1]\n",
    "flag = (prob_test >= threshold).astype(int)\n",
    "print(\"Threshold for 'unsafe'        :\", threshold)\n",
    "print(\"Recall at threshold 0.3       :\", round(recall_score(y_test, flag), 3))\n",
    "print(\"Precision at threshold 0.3    :\", round(precision_score(y_test, flag), 3))\n",
    "print(\"Unsafe sources missed at 0.3  :\", int(((flag == 0) & (y_test == 1)).sum()), \"of\", int(y_test.sum()))\n",
    "print()\n",
    "\n",
    "# Chart 5: confusion matrix.\n",
    "cm = confusion_matrix(y_test, pred, labels=[1, 0])\n",
    "plt.figure(figsize=(5.5, 4.5))\n",
    "plt.imshow(cm, cmap=\"Blues\")\n",
    "labels = [[\"TP\", \"FN\"], [\"FP\", \"TN\"]]\n",
    "for r in range(2):\n",
    "    for c in range(2):\n",
    "        plt.text(c, r, labels[r][c] + \"\\n\" + str(cm[r][c]), ha=\"center\", va=\"center\", fontsize=13,\n",
    "                 color=\"white\" if cm[r][c] > cm.max() / 2 else \"black\")\n",
    "plt.xticks([0, 1], [\"Predicted unsafe\", \"Predicted safe\"])\n",
    "plt.yticks([0, 1], [\"Unsafe\", \"Safe\"])\n",
    "plt.title(\"Confusion matrix (test samples)\")\n",
    "plt.tight_layout()\n",
    "plt.savefig(\"chart5_confusion.png\")\n",
    "plt.close()\n",
    "\n",
    "# Chart 6: which readings the forest relied on.\n",
    "importance = pd.Series(model[-1].feature_importances_, index=features).sort_values()\n",
    "print(\"Feature importance:\")\n",
    "print(importance.round(3).to_string())\n",
    "print()\n",
    "plt.figure(figsize=(7.5, 5.2))\n",
    "plt.barh(importance.index, importance.values, color=\"#59a14f\")\n",
    "plt.title(\"What the Random Forest relied on\")\n",
    "plt.xlabel(\"Importance\")\n",
    "plt.tight_layout()\n",
    "plt.savefig(\"chart6_importance.png\")\n",
    "plt.close()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Step 9 - Deployment"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(\"--- Step 9: Deployment ---\")\n",
    "joblib.dump(model, \"water_model.pkl\")\n",
    "model = joblib.load(\"water_model.pkl\")\n",
    "new = pd.DataFrame({\n",
    "    \"ph\": [7.2, 7.8, 6.9], \"tds_mg_l\": [380, 1250, 520], \"turbidity_ntu\": [0.4, 2.1, 9.5],\n",
    "    \"nitrate_mg_l\": [8.0, 30.0, np.nan], \"fluoride_mg_l\": [0.3, np.nan, 0.6], \"chloride_mg_l\": [110, 380, 150],\n",
    "    \"residual_chlorine_mg_l\": [0.4, 0.0, 0.0], \"source_Hand pump\": [0, 1, 0], \"source_Open well\": [0, 0, 1],\n",
    "    \"source_Piped supply\": [1, 0, 0], \"source_Pond\": [0, 0, 0], \"source_Tube well\": [0, 0, 0],\n",
    "})\n",
    "names = [\"Piped supply, chlorinated\", \"Hand pump, fluoride not tested\", \"Open well, cloudy, nitrate not tested\"]\n",
    "prob = model.predict_proba(new)[:, 1]\n",
    "for i in range(len(names)):\n",
    "    if prob[i] >= threshold:\n",
    "        verdict = \"LIKELY UNSAFE - warn users, send to the lab first\"\n",
    "    else:\n",
    "        verdict = \"likely safe - confirm with the lab test\"\n",
    "    print(names[i].ljust(38), round(prob[i], 2), \"->\", verdict)\n",
    "print()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Step 10 - Feedback"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(\"--- Step 10: Feedback ---\")\n",
    "# After the monsoon, 250 more sources are tested. Rain washes dirt and\n",
    "# sewage into wells and ponds, so contamination rises.\n",
    "mon = pd.read_csv(\"monsoon_samples.csv\")\n",
    "mon[\"unsafe\"] = (mon[\"verdict\"] == \"Unsafe\").astype(int)\n",
    "mon = pd.get_dummies(mon, columns=[\"source\"], dtype=int)\n",
    "mon_pred = (model.predict_proba(mon[features])[:, 1] >= threshold).astype(int)\n",
    "print(\"Monsoon samples                    :\", len(mon))\n",
    "print(\"Share unsafe, before the monsoon   :\", round(y.mean() * 100, 1), \"%\")\n",
    "print(\"Share unsafe, after the monsoon    :\", round(mon[\"unsafe\"].mean() * 100, 1), \"%\")\n",
    "print(\"Recall on the test samples (0.3)   :\", round(recall_score(y_test, flag), 3))\n",
    "print(\"Recall on the monsoon samples      :\", round(recall_score(mon[\"unsafe\"], mon_pred), 3))\n",
    "print(\"Precision on the monsoon samples   :\", round(precision_score(mon[\"unsafe\"], mon_pred), 3))\n",
    "if recall_score(mon[\"unsafe\"], mon_pred) < recall_score(y_test, flag) - 0.05:\n",
    "    print(\"Feedback: recall has fallen after the monsoon - add monsoon samples and retrain.\")\n",
    "else:\n",
    "    print(\"Feedback: recall holds after the monsoon - the model can stay in use.\")"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
