{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "d05b59f5",
   "metadata": {},
   "source": [
    "# Lab 1: A Neural Network with One Node\n",
    "\n",
    "**Special Topics in Artificial Intelligence (STAI 2026)**\n",
    "\n",
    "In this lab you build the smallest possible neural network: **one node in one layer**. You will:\n",
    "\n",
    "1. load a real engineering dataset (concrete compressive strength),\n",
    "2. prepare the data: split and scale,\n",
    "3. build a baseline,\n",
    "4. build and **train** a one-node network,\n",
    "5. evaluate it and use it for **inference** on a new concrete mix.\n",
    "\n",
    "**How to run:** in Google Colab, choose *Runtime > Run all*. No GPU is needed; everything runs on the CPU in under a minute. Run the cells in order, and read the text between them: the text explains what each step does and why."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9033c21b",
   "metadata": {},
   "source": [
    "## 1) Setup\n",
    "\n",
    "Everything we need (PyTorch, pandas, matplotlib, scikit-learn) is already installed in Google Colab, so there is nothing to install. The random seeds make the results repeatable."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "18766c00",
   "metadata": {},
   "outputs": [],
   "source": [
    "import numpy as np\n",
    "import pandas as pd\n",
    "import torch\n",
    "import torch.nn as nn\n",
    "import matplotlib.pyplot as plt\n",
    "from sklearn.model_selection import train_test_split\n",
    "from sklearn.preprocessing import StandardScaler\n",
    "\n",
    "torch.manual_seed(0)\n",
    "np.random.seed(0)\n",
    "print('PyTorch version:', torch.__version__)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "0dd7d1d5",
   "metadata": {},
   "source": [
    "## 2) The dataset: concrete compressive strength\n",
    "\n",
    "We use the **Concrete Compressive Strength** dataset (file: `Concrete Compressive Strength.csv`). It is the same engineering example as in the Week 3 lecture: each row is one concrete mix that was prepared and tested in a laboratory.\n",
    "\n",
    "**Inputs (8 columns, all numeric):** cement, blast furnace slag, fly ash, water, superplasticizer, coarse aggregate, fine aggregate (all in kg per cubic meter), and the age of the concrete in days.\n",
    "\n",
    "**Target (1 column):** the measured compressive strength in MPa.\n",
    "\n",
    "This is a **regression** task: the target is a number, not a category. Every input is already a number, so no one-hot encoding is needed.\n",
    "\n",
    "**Loading the file:** the next cell looks for the CSV next to the notebook. In Google Colab the file is not there yet, so the cell opens an **upload dialog**: choose `Concrete Compressive Strength.csv` from your computer, wait until the upload finishes, and the data loads automatically.\n",
    "\n",
    "**Dataset citation:** Yeh, I. (1998). Concrete Compressive Strength [Dataset]. UCI Machine Learning Repository. https://doi.org/10.24432/C5PK67."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "b9b2ae80",
   "metadata": {},
   "outputs": [],
   "source": [
    "from pathlib import Path\n",
    "\n",
    "CSV_NAME = 'Concrete Compressive Strength.csv'\n",
    "\n",
    "if not Path(CSV_NAME).exists():\n",
    "    # Running in Google Colab: open the upload dialog and choose the CSV file.\n",
    "    from google.colab import files\n",
    "    files.upload()\n",
    "\n",
    "df = pd.read_csv(CSV_NAME)\n",
    "df.columns = df.columns.str.strip()        # clean stray spaces in column names\n",
    "\n",
    "TARGET = 'Concrete compressive strength'\n",
    "X_df = df.drop(columns=[TARGET])           # 8 input columns\n",
    "y_df = df[[TARGET]]                        # 1 target column\n",
    "\n",
    "print('Inputs:', X_df.shape, '| Target:', y_df.shape)\n",
    "df.head()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9354d89f",
   "metadata": {},
   "source": [
    "Look at the summary below. The columns have very different ranges, which is why we will scale them before training."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "8d8b02ef",
   "metadata": {},
   "outputs": [],
   "source": [
    "df.describe().round(2)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "811ec05d",
   "metadata": {},
   "source": [
    "## 3) Split the data: training and test\n",
    "\n",
    "From Week 2: a model must be evaluated on data it has never seen. We keep 80% of the rows for training and 20% for the final test.\n",
    "\n",
    "In a full project we would also keep a validation set for choosing settings. This lab uses fixed settings, so a single train/test split is enough."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "8225bb61",
   "metadata": {},
   "outputs": [],
   "source": [
    "X = X_df.values.astype(np.float32)\n",
    "y = y_df.values.astype(np.float32)        # shape (1030, 1)\n",
    "\n",
    "X_train, X_test, y_train, y_test = train_test_split(\n",
    "    X, y, test_size=0.2, random_state=42)\n",
    "\n",
    "print('Training rows:', X_train.shape[0])\n",
    "print('Test rows:    ', X_test.shape[0])"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c0343c96",
   "metadata": {},
   "source": [
    "## 4) Scale the features\n",
    "\n",
    "The columns have very different ranges (compare cement, around 100 to 500, with superplasticizer, around 0 to 32). Training is more stable when every input has a similar scale, so we standardize each column: subtract the mean and divide by the standard deviation.\n",
    "\n",
    "**Important rule from Week 4:** the scaler is fitted on the **training data only**, and then applied to both sets. Fitting it on all data would leak information from the test set into training."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "3938d1a3",
   "metadata": {},
   "outputs": [],
   "source": [
    "scaler = StandardScaler()\n",
    "scaler.fit(X_train)                        # statistics come from training data only\n",
    "\n",
    "X_train_s = scaler.transform(X_train)\n",
    "X_test_s  = scaler.transform(X_test)\n",
    "\n",
    "print('Training mean per column (after scaling):', X_train_s.mean(axis=0).round(2))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "861cca68",
   "metadata": {},
   "source": [
    "## 5) A baseline before any network\n",
    "\n",
    "From Week 2: start with a baseline. The simplest possible prediction is the **average strength of the training data**, used for every test mix. Any network we train later has to beat this number to be worth its complexity."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "4f88df92",
   "metadata": {},
   "outputs": [],
   "source": [
    "baseline_prediction = y_train.mean()\n",
    "baseline_mae = np.abs(y_test - baseline_prediction).mean()\n",
    "\n",
    "print(f'Baseline prediction: always {baseline_prediction:.1f} MPa')\n",
    "print(f'Baseline test MAE:   {baseline_mae:.2f} MPa')"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "ff5da6e0",
   "metadata": {},
   "source": [
    "## 6) One node, one layer\n",
    "\n",
    "A single node does one thing: it multiplies each input by a weight, adds everything together with a bias, and outputs the result:\n",
    "\n",
    "$$\\hat{y} = w_1 x_1 + w_2 x_2 + \\dots + w_8 x_8 + b$$\n",
    "\n",
    "That is the whole network. In PyTorch this is `nn.Linear(8, 1)`: 8 inputs, 1 output.\n",
    "\n",
    "**Count the parameters** (as in the quiz): 8 weights plus 1 bias, so **9 parameters** in total. There is no activation function here because the output is a number in MPa, not a probability."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "32bbde9b",
   "metadata": {},
   "outputs": [],
   "source": [
    "model = nn.Sequential(\n",
    "    nn.Linear(8, 1)      # one node: 8 inputs -> 1 output\n",
    ")\n",
    "\n",
    "n_params = sum(p.numel() for p in model.parameters())\n",
    "print(model)\n",
    "print('Total parameters:', n_params)   # 8 weights + 1 bias = 9"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b0b81121",
   "metadata": {},
   "source": [
    "## 7) Training\n",
    "\n",
    "Training repeats four steps, many times:\n",
    "\n",
    "1. **Forward:** the model predicts strengths for the training mixes.\n",
    "2. **Loss:** the mean squared error (MSE) measures how wrong the predictions are.\n",
    "3. **Backward:** PyTorch computes how each weight should change to reduce the loss.\n",
    "4. **Update:** the optimizer (Adam) changes the weights a small step in that direction.\n",
    "\n",
    "One pass through these four steps is called an **epoch**. We run 1,500 epochs and record the loss, so we can see the network learn."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "b2acce29",
   "metadata": {},
   "outputs": [],
   "source": [
    "X_train_t = torch.tensor(X_train_s)\n",
    "y_train_t = torch.tensor(y_train)\n",
    "X_test_t  = torch.tensor(X_test_s)\n",
    "\n",
    "loss_fn   = nn.MSELoss()\n",
    "optimizer = torch.optim.Adam(model.parameters(), lr=0.05)\n",
    "\n",
    "losses = []\n",
    "for epoch in range(1500):\n",
    "    optimizer.zero_grad()            # clear old gradients\n",
    "    prediction = model(X_train_t)    # 1) forward\n",
    "    loss = loss_fn(prediction, y_train_t)   # 2) loss\n",
    "    loss.backward()                  # 3) backward\n",
    "    optimizer.step()                 # 4) update\n",
    "    losses.append(loss.item())\n",
    "    if (epoch + 1) % 250 == 0:\n",
    "        print(f'Epoch {epoch+1:4d} | training MSE: {loss.item():8.2f}')"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "895ed96d",
   "metadata": {},
   "outputs": [],
   "source": [
    "plt.figure(figsize=(7, 4))\n",
    "plt.plot(losses)\n",
    "plt.xlabel('Epoch')\n",
    "plt.ylabel('Training MSE')\n",
    "plt.title('The loss falls as the node learns')\n",
    "plt.grid(alpha=0.3)\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "32639d90",
   "metadata": {},
   "source": [
    "## 8) Evaluation on the test set\n",
    "\n",
    "Now the honest check: how well does the trained node predict mixes it has never seen? We report the **MAE** (mean absolute error, from Week 4): the average size of the error in MPa, easy to interpret."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c576f039",
   "metadata": {},
   "outputs": [],
   "source": [
    "model.eval()\n",
    "with torch.no_grad():\n",
    "    y_pred = model(X_test_t).numpy()\n",
    "\n",
    "mae  = np.abs(y_test - y_pred).mean()\n",
    "rmse = np.sqrt(((y_test - y_pred) ** 2).mean())\n",
    "\n",
    "print(f'Baseline test MAE:  {baseline_mae:.2f} MPa')\n",
    "print(f'One-node test MAE:  {mae:.2f} MPa')\n",
    "print(f'One-node test RMSE: {rmse:.2f} MPa')"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "aab3b077",
   "metadata": {},
   "outputs": [],
   "source": [
    "plt.figure(figsize=(5.5, 5.5))\n",
    "plt.scatter(y_test, y_pred, s=14, alpha=0.6)\n",
    "lims = [0, 85]\n",
    "plt.plot(lims, lims, 'r--', lw=1, label='perfect prediction')\n",
    "plt.xlabel('Actual strength (MPa)')\n",
    "plt.ylabel('Predicted strength (MPa)')\n",
    "plt.title('One node: predicted versus actual')\n",
    "plt.legend(); plt.grid(alpha=0.3)\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4a18fa38",
   "metadata": {},
   "source": [
    "The node clearly beats the baseline: it has learned a useful relationship between the mix and the strength. The scatter plot also shows its limit: the points bend away from the red line, because one node can only learn a **straight-line** relationship, and concrete does not behave that simply (for example, strength grows quickly in the first days and then flattens).\n",
    "\n",
    "## 9) Inference: predict a new concrete mix\n",
    "\n",
    "Inference means using the trained model on new data. Below is one new mix. **Important:** the new input must be scaled with the same scaler that was fitted on the training data."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "ede06d95",
   "metadata": {},
   "outputs": [],
   "source": [
    "new_mix = pd.DataFrame([{\n",
    "    'Cement': 320.0,\n",
    "    'Blast Furnace Slag': 0.0,\n",
    "    'Fly Ash': 100.0,\n",
    "    'Water': 180.0,\n",
    "    'Superplasticizer': 8.0,\n",
    "    'Coarse Aggregate': 970.0,\n",
    "    'Fine Aggregate': 780.0,\n",
    "    'Age (day)': 28,\n",
    "}])\n",
    "\n",
    "new_mix = new_mix[X_df.columns]            # same column order as training\n",
    "new_scaled = scaler.transform(new_mix.values.astype(np.float32))\n",
    "with torch.no_grad():\n",
    "    predicted = model(torch.tensor(new_scaled)).item()\n",
    "\n",
    "print(f'Predicted compressive strength at 28 days: {predicted:.1f} MPa')"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "923462a1",
   "metadata": {},
   "source": [
    "## 10) What you learned, and what comes next\n",
    "\n",
    "- A neural network node is a weighted sum plus a bias: 9 parameters in our case.\n",
    "- Training = forward, loss, backward, update, repeated for many epochs.\n",
    "- The one-node network beats the mean baseline, but it can only draw a straight line through the data.\n",
    "\n",
    "**In Lab 2** we stack more nodes into hidden layers and add an activation function, so the network can learn curved relationships, and we check how much the error improves.\n",
    "\n",
    "### Exercises\n",
    "\n",
    "1. Change the learning rate to `0.5` and to `0.001` and rerun the training cell. What happens to the loss curve in each case?\n",
    "2. Change `Age (day)` in the new mix to 3 days and to 90 days. Does the prediction change in the direction you expect?\n",
    "3. Print `model[0].weight` and find out which input has the largest positive weight. Does it match your engineering intuition?"
   ]
  }
 ],
 "metadata": {
  "colab": {
   "provenance": []
  },
  "kernelspec": {
   "display_name": "Python 3",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
