{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Is This SMS a Scam?\n",
    "\n",
    "scam_sms.py\n",
    "\n",
    "AI project on Natural Language Processing: spotting scam text messages -\n",
    "fake prizes, fake KYC alerts, fake job offers - before someone is cheated.\n",
    "\n",
    "CBSE Class X, Artificial Intelligence (417), Part C project work.\n",
    "SDG 16 (Peace, Justice and Strong Institutions) and SDG 9 (Industry,\n",
    "Innovation and Infrastructure).\n",
    "\n",
    "Each message is turned into a BAG OF WORDS: how many times each word of\n",
    "the vocabulary appears in it. The model then builds two \"average messages\"\n",
    "from the training data - an average scam and an average genuine message -\n",
    "and asks of each new message: which average does it share more words with?\n",
    "\n",
    "How much two messages share is measured with cosine similarity:\n",
    "    similarity = (sum of count1 x count2 over every word)\n",
    "                 / (length of message 1 x length of message 2)\n",
    "It is 1 for messages with exactly the same mix of words and 0 for messages\n",
    "with no word in common.\n",
    "\n",
    "Libraries: pandas (the messages), numpy (the arithmetic), matplotlib."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Stage 1 - Problem scoping"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(\"=\" * 60)\n",
    "print(\"IS THIS SMS A SCAM?\")\n",
    "print(\"=\" * 60)\n",
    "print(\"Who   : mobile phone users, especially the elderly and first-time users\")\n",
    "print(\"What  : scam messages trick people into paying or sharing an OTP\")\n",
    "print(\"Where : on every phone, every day\")\n",
    "print(\"Why   : a warning at the right moment can stop a fraud\")\n",
    "print()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Stage 2 - Data acquisition"
   ]
  },
  {
   "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",
    "\n",
    "pd.set_option(\"display.width\", 140)\n",
    "pd.set_option(\"display.max_colwidth\", 100)\n",
    "\n",
    "data = pd.read_csv(\"sms_messages.csv\")\n",
    "\n",
    "print(\"--- Stage 2: Data acquisition ---\")\n",
    "print(\"Messages read :\", len(data))\n",
    "print()\n",
    "print(data.head(6).to_string(index=False))\n",
    "print()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Stage 3 - Data exploration"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(\"--- Stage 3: Data exploration ---\")\n",
    "before = len(data)\n",
    "data = data.drop_duplicates()\n",
    "print(\"Messages stored twice :\", before - len(data), \"(removed)\")\n",
    "print(\"Messages left         :\", len(data))\n",
    "print(data[\"label\"].value_counts().to_string())\n",
    "print()\n",
    "\n",
    "stop_words = [\"the\", \"a\", \"an\", \"is\", \"are\", \"to\", \"for\", \"of\", \"in\", \"on\", \"at\", \"your\",\n",
    "              \"you\", \"it\", \"and\", \"with\", \"be\", \"will\", \"has\", \"have\", \"been\", \"this\", \"we\",\n",
    "              \"our\", \"my\", \"me\", \"i\", \"or\", \"us\", \"if\"]\n",
    "\n",
    "# Text normalisation. Two extra steps for SMS: a web link becomes the\n",
    "# single word \"link\", and any run of digits becomes the word \"number\" -\n",
    "# the exact link or number does not matter, but having one does.\n",
    "tokens_list = []\n",
    "for text in data[\"text\"]:\n",
    "    words = []\n",
    "    for raw in text.lower().split():\n",
    "        if raw.startswith(\"http\"):\n",
    "            words.append(\"link\")\n",
    "            continue\n",
    "        clean = \"\"\n",
    "        previous_digit = False\n",
    "        for ch in raw:\n",
    "            if ch.isdigit():\n",
    "                if not previous_digit:          # a run of digits is one \"number\"\n",
    "                    clean = clean + \" number \"\n",
    "                previous_digit = True\n",
    "                continue\n",
    "            previous_digit = False\n",
    "            if ch.isalpha():\n",
    "                clean = clean + ch\n",
    "            elif ch != \",\":                     # 25,00,000 is still one number\n",
    "                clean = clean + \" \"\n",
    "        for w in clean.split():\n",
    "            if w not in stop_words:\n",
    "                words.append(w)\n",
    "    tokens_list.append(words)\n",
    "data[\"tokens\"] = tokens_list\n",
    "\n",
    "print(\"Text normalisation of one message:\")\n",
    "print(\"  Original :\", data.iloc[0][\"text\"])\n",
    "print(\"  Tokens   :\", data.iloc[0][\"tokens\"])\n",
    "print()\n",
    "\n",
    "vocabulary = []\n",
    "for tokens in data[\"tokens\"]:\n",
    "    for w in tokens:\n",
    "        if w not in vocabulary:\n",
    "            vocabulary.append(w)\n",
    "print(\"Words in the vocabulary :\", len(vocabulary))\n",
    "\n",
    "# The Bag of Words: one row per message, one column per vocabulary word.\n",
    "bow = np.zeros((len(data), len(vocabulary)))\n",
    "for m in range(len(data)):\n",
    "    for w in data.iloc[m][\"tokens\"]:\n",
    "        bow[m, vocabulary.index(w)] = bow[m, vocabulary.index(w)] + 1\n",
    "print(\"Bag of Words table size  :\", bow.shape[0], \"messages x\", bow.shape[1], \"words\")\n",
    "print()\n",
    "\n",
    "# Which words are most typical of each kind? (share of messages using them)\n",
    "is_scam = (data[\"label\"] == \"scam\").values\n",
    "scam_share = (bow[is_scam] > 0).mean(axis=0) * 100\n",
    "genuine_share = (bow[~is_scam] > 0).mean(axis=0) * 100\n",
    "words = pd.DataFrame({\"scam %\": scam_share, \"genuine %\": genuine_share}, index=vocabulary)\n",
    "words[\"difference\"] = words[\"scam %\"] - words[\"genuine %\"]\n",
    "top = words.sort_values(\"difference\")\n",
    "print(\"Words most typical of scams:\")\n",
    "print(top.tail(6)[::-1].round(1))\n",
    "print()\n",
    "\n",
    "# Chart 1: the words most typical of scams and of genuine messages.\n",
    "pick = pd.concat([top.head(8), top.tail(8)])\n",
    "plt.figure(figsize=(7, 5.2))\n",
    "colours = []\n",
    "for v in pick[\"difference\"]:\n",
    "    if v > 0:\n",
    "        colours.append(\"#e15759\")\n",
    "    else:\n",
    "        colours.append(\"#59a14f\")\n",
    "plt.barh(pick.index, pick[\"difference\"], color=colours)\n",
    "plt.axvline(0, color=\"#999999\")\n",
    "plt.title(\"Words that give a scam away (red) and genuine words (green)\")\n",
    "plt.xlabel(\"% of scams using it  minus  % of genuine messages using it\")\n",
    "plt.tight_layout()\n",
    "plt.savefig(\"chart1_telltale_words.png\")\n",
    "plt.close()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Stage 4 - Modelling"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(\"--- Stage 4: Modelling ---\")\n",
    "order = np.random.default_rng(3).permutation(len(data))\n",
    "cut = int(len(data) * 0.8)\n",
    "train_rows = order[:cut]\n",
    "test_rows = order[cut:]\n",
    "print(\"Training messages :\", len(train_rows))\n",
    "print(\"Testing messages  :\", len(test_rows))\n",
    "print()\n",
    "\n",
    "train_scam = train_rows[is_scam[train_rows]]\n",
    "train_genuine = train_rows[~is_scam[train_rows]]\n",
    "average_scam = bow[train_scam].mean(axis=0)\n",
    "average_genuine = bow[train_genuine].mean(axis=0)\n",
    "\n",
    "# For every testing message: similarity to the average scam minus\n",
    "# similarity to the average genuine message. Above the threshold = scam.\n",
    "gaps = []\n",
    "for m in test_rows:\n",
    "    v = bow[m]\n",
    "    length = np.sqrt((v ** 2).sum())\n",
    "    if length == 0:\n",
    "        gaps.append(0)\n",
    "        continue\n",
    "    sim_scam = (v * average_scam).sum() / (length * np.sqrt((average_scam ** 2).sum()))\n",
    "    sim_genuine = (v * average_genuine).sum() / (length * np.sqrt((average_genuine ** 2).sum()))\n",
    "    gaps.append(sim_scam - sim_genuine)\n",
    "gaps = np.array(gaps)\n",
    "actual = is_scam[test_rows]\n",
    "\n",
    "threshold = 0.0\n",
    "predicted = gaps > threshold"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Stage 5 - Evaluation"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(\"--- Stage 5: Evaluation ---\")\n",
    "tp = (predicted & actual).sum()\n",
    "fn = (~predicted & actual).sum()\n",
    "fp = (predicted & ~actual).sum()\n",
    "tn = (~predicted & ~actual).sum()\n",
    "accuracy = (tp + tn) / len(actual) * 100\n",
    "precision = tp / (tp + fp) * 100\n",
    "recall = tp / (tp + fn) * 100\n",
    "f1 = 2 * precision * recall / (precision + recall)\n",
    "print(\"TP, FN, FP, TN :\", tp, fn, fp, tn)\n",
    "print(\"Accuracy       :\", round(accuracy, 1), \"%\")\n",
    "print(\"Precision      :\", round(precision, 1), \"%\")\n",
    "print(\"Recall         :\", round(recall, 1), \"%\")\n",
    "print(\"F1 score       :\", round(f1, 1), \"%\")\n",
    "print()\n",
    "\n",
    "print(\"Messages the model got wrong:\")\n",
    "for i in range(len(test_rows)):\n",
    "    if predicted[i] != actual[i]:\n",
    "        if actual[i]:\n",
    "            label = \"scam\"\n",
    "        else:\n",
    "            label = \"genuine\"\n",
    "        print(\"  [really \" + label + \"]\", data.iloc[test_rows[i]][\"text\"])\n",
    "print()\n",
    "\n",
    "# Chart 2: confusion matrix.\n",
    "grid = [[tp, fn], [fp, tn]]\n",
    "plt.figure(figsize=(5, 4.3))\n",
    "plt.imshow(grid, cmap=\"Reds\")\n",
    "names = [[\"TP\", \"FN\"], [\"FP\", \"TN\"]]\n",
    "for r in range(2):\n",
    "    for c in range(2):\n",
    "        if grid[r][c] > max(max(grid)) / 2:\n",
    "            ink = \"white\"\n",
    "        else:\n",
    "            ink = \"black\"\n",
    "        plt.text(c, r, names[r][c] + \"\\n\" + str(grid[r][c]), ha=\"center\", va=\"center\", fontsize=14, color=ink)\n",
    "plt.xticks([0, 1], [\"Said scam\", \"Said genuine\"])\n",
    "plt.yticks([0, 1], [\"Scam\", \"Genuine\"])\n",
    "plt.xlabel(\"What the model said\")\n",
    "plt.ylabel(\"The truth\")\n",
    "plt.title(\"Confusion matrix (testing messages)\")\n",
    "plt.tight_layout()\n",
    "plt.savefig(\"chart2_matrix.png\")\n",
    "plt.close()\n",
    "\n",
    "# Both mistakes hurt here: a missed scam can empty a bank account, but a\n",
    "# genuine OTP or bank alert marked as scam may never be read. So every\n",
    "# threshold is tried, to see the trade-off.\n",
    "thresholds = np.round(np.arange(-0.3, 0.31, 0.05), 2)\n",
    "p_list = []\n",
    "r_list = []\n",
    "f_list = []\n",
    "for t in thresholds:\n",
    "    pred = gaps > t\n",
    "    tp_t = (pred & actual).sum()\n",
    "    fp_t = (pred & ~actual).sum()\n",
    "    fn_t = (~pred & actual).sum()\n",
    "    p = 0\n",
    "    if tp_t + fp_t > 0:\n",
    "        p = tp_t / (tp_t + fp_t) * 100\n",
    "    r = tp_t / (tp_t + fn_t) * 100\n",
    "    f = 0\n",
    "    if p + r > 0:\n",
    "        f = 2 * p * r / (p + r)\n",
    "    p_list.append(p)\n",
    "    r_list.append(r)\n",
    "    f_list.append(f)\n",
    "best = int(np.argmax(f_list))\n",
    "print(\"Threshold with the best F1 score :\", round(thresholds[best], 2))\n",
    "print(\"Best F1 score                    :\", round(f_list[best], 1), \"%\")\n",
    "print()\n",
    "\n",
    "# Chart 3: precision, recall and F1 for every threshold.\n",
    "plt.figure(figsize=(7.5, 4.5))\n",
    "plt.plot(thresholds, r_list, marker=\"o\", color=\"#e15759\", label=\"Recall (scams caught)\")\n",
    "plt.plot(thresholds, p_list, marker=\"s\", color=\"#4e79a7\", label=\"Precision (warnings that were right)\")\n",
    "plt.plot(thresholds, f_list, marker=\"^\", color=\"#59a14f\", label=\"F1 score\")\n",
    "plt.title(\"Moving the threshold\")\n",
    "plt.xlabel(\"Threshold (similarity to scams minus similarity to genuine)\")\n",
    "plt.ylabel(\"Percent\")\n",
    "plt.ylim(0, 105)\n",
    "plt.legend()\n",
    "plt.grid(True, linestyle=\"--\", alpha=0.4)\n",
    "plt.tight_layout()\n",
    "plt.savefig(\"chart3_threshold.png\")\n",
    "plt.close()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Stage 6 - Deployment"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(\"--- Stage 6: Deployment ---\")\n",
    "new_messages = [\n",
    "    \"Your OTP for payment is 5512. Do not share it with anyone.\",\n",
    "    \"Dear customer, your KYC has expired. Update now at http://kyc-update.example or account will be blocked\",\n",
    "    \"Mummy, I reached school. Will call at lunch\",\n",
    "    \"Congratulations you won Rs 50,000 in our lucky draw, click http://claim-prize.example\",\n",
    "]\n",
    "for text in new_messages:\n",
    "    words = []\n",
    "    for raw in text.lower().split():\n",
    "        if raw.startswith(\"http\"):\n",
    "            words.append(\"link\")\n",
    "            continue\n",
    "        clean = \"\"\n",
    "        previous_digit = False\n",
    "        for ch in raw:\n",
    "            if ch.isdigit():\n",
    "                if not previous_digit:          # a run of digits is one \"number\"\n",
    "                    clean = clean + \" number \"\n",
    "                previous_digit = True\n",
    "                continue\n",
    "            previous_digit = False\n",
    "            if ch.isalpha():\n",
    "                clean = clean + ch\n",
    "            elif ch != \",\":                     # 25,00,000 is still one number\n",
    "                clean = clean + \" \"\n",
    "        for w in clean.split():\n",
    "            if w not in stop_words:\n",
    "                words.append(w)\n",
    "    v = np.zeros(len(vocabulary))\n",
    "    for w in words:\n",
    "        if w in vocabulary:                   # a word never seen in training is ignored\n",
    "            v[vocabulary.index(w)] = v[vocabulary.index(w)] + 1\n",
    "    length = np.sqrt((v ** 2).sum())\n",
    "    gap = 0\n",
    "    if length > 0:\n",
    "        gap = ((v * average_scam).sum() / (length * np.sqrt((average_scam ** 2).sum()))\n",
    "               - (v * average_genuine).sum() / (length * np.sqrt((average_genuine ** 2).sum())))\n",
    "    if gap > threshold:\n",
    "        verdict = \"WARNING: looks like a scam\"\n",
    "    else:\n",
    "        verdict = \"looks genuine\"\n",
    "    print(text[:60].ljust(60), str(round(gap, 2)).rjust(6), \"->\", verdict)"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
