1244 lines
53 KiB
Plaintext
1244 lines
53 KiB
Plaintext
{
|
||
"cells": [
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "721c78fe",
|
||
"metadata": {},
|
||
"source": [
|
||
"# Lab 1 - Fuzzy Logic"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "d7e56034-255b-4abb-bda4-2336b7678e5f",
|
||
"metadata": {},
|
||
"source": [
|
||
"## Objective\n",
|
||
"\n",
|
||
"The goal of this laboratory session is to become familiar with fundamentally different approaches to building decision-making systems:\n",
|
||
"\n",
|
||
"1. **A classical Fuzzy Logic System (FLS)** - based on expert knowledge encoded as human-readable IF-THEN rules\n",
|
||
"2. **An artificial neural network used as an FLS imitator (ANN-imitator)** - trained on data generated by the FLS, so that it reproduces the FLS behaviour\n",
|
||
"3. **An artificial neural network used as a state predictor (ANN-predictor)** - trained on data from real IoT sensors, forecasting how the indoor climate will change in the next time step\n",
|
||
"\n",
|
||
"By working through all three systems you will learn how to:\n",
|
||
"\n",
|
||
"- define linguistic variables and fuzzy sets,\n",
|
||
"- write fuzzy IF-THEN rules,\n",
|
||
"- implement a simple fuzzy inference engine from scratch,\n",
|
||
"- train a neural network on data produced by the FLS,\n",
|
||
"- compare the experimental results and draw conclusions about interpretability versus flexibility."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "9240b3ea-80fa-4c34-b66e-98a684797549",
|
||
"metadata": {},
|
||
"source": [
|
||
"---\n",
|
||
"\n",
|
||
"**Please double-click a cell to edit it.**\n",
|
||
"\n",
|
||
"**Before submitting your solution, please fill in the following information:**\n",
|
||
"\n",
|
||
"| Name | Student ID | Date |\n",
|
||
"|---|---|---|\n",
|
||
"| name and surname | student ID number | date |\n",
|
||
"\n",
|
||
"Please send the completed file (you can export it to html, pdf or ipynb) to: mkasprowicz@atins.pl\n",
|
||
"\n",
|
||
"**Submission deadline**: Tuesday, November 10th, 2026\n",
|
||
"\n",
|
||
"Because every submitted report has to be reviewed, **please make sure to respect the deadline**.\n",
|
||
"\n",
|
||
"## Task Context\n",
|
||
"This exercise is inspired by the paper *Occupancy-driven energy-efficient control* — <a href=\"https://www.sciencedirect.com/science/article/abs/pii/S0360132313003120\">ScienceDirect</a>.\n",
|
||
"\n",
|
||
"To work with this notebook you also need the data file (`datatest.csv`), which is available for download at academia.michalkasprowicz.com."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": null,
|
||
"id": "a978f93e",
|
||
"metadata": {},
|
||
"outputs": [],
|
||
"source": [
|
||
"# Install the required libraries (run once)\n",
|
||
"!pip install numpy matplotlib scikit-fuzzy torch scikit-learn"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": null,
|
||
"id": "cc3621b2",
|
||
"metadata": {},
|
||
"outputs": [],
|
||
"source": [
|
||
"# Import all necessary libraries\n",
|
||
"import numpy as np\n",
|
||
"import matplotlib.pyplot as plt\n",
|
||
"import skfuzzy as fuzz\n",
|
||
"from skfuzzy import control as ctrl\n",
|
||
"import torch\n",
|
||
"import torch.nn as nn\n",
|
||
"import pandas as pd\n",
|
||
"from torch.utils.data import DataLoader, TensorDataset\n",
|
||
"from sklearn.model_selection import train_test_split\n",
|
||
"from sklearn.preprocessing import MinMaxScaler\n",
|
||
"from sklearn.metrics import mean_squared_error, mean_absolute_error\n",
|
||
"import warnings\n",
|
||
"warnings.filterwarnings('ignore')\n",
|
||
"\n",
|
||
"# Fix the random seeds so results are reproducible\n",
|
||
"np.random.seed(42)\n",
|
||
"torch.manual_seed(42)"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "df7a2839",
|
||
"metadata": {},
|
||
"source": [
|
||
"## Experiment 1 - Fuzzy Logic System (FLS)\n",
|
||
"\n",
|
||
"**Goal of this step**: Build a fuzzy controller from scratch. Based on five input variables (temperature, humidity, number of people, $CO_2$, time of day), it decides the fan power level.\n",
|
||
"\n",
|
||
"Think of it as encoding the reasoning of an experienced building technician: *\"If it's hot AND humid, run the fan at full power.\"* The FLS formalizes exactly this kind of common-sense reasoning.\n",
|
||
"\n",
|
||
"### Defining the universes of discourse\n",
|
||
"\n",
|
||
"Every linguistic variable operates over a specific range of values, called its *universe of discourse*. Before running the code, analyse the ranges below - do they correspond to realistic conditions in a conference room? If a range is too narrow, real sensor readings may fall outside it; if it is too wide, the fuzzy sets become stretched and lose resolution."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": null,
|
||
"id": "71551a05",
|
||
"metadata": {},
|
||
"outputs": [],
|
||
"source": [
|
||
"# ============================================================\n",
|
||
"# STEP 1.1 – Define the universes of discourse\n",
|
||
"# ============================================================\n",
|
||
"\n",
|
||
"# Universes of the input variables\n",
|
||
"u_temp = np.arange(15, 35, 0.1) # temperature: 15 - 35°C\n",
|
||
"u_hum = np.arange(20, 90, 0.5) # humidity: 20 - 90%\n",
|
||
"u_occ = np.arange(0, 100, 1) # occupancy: 0 - 100 people\n",
|
||
"u_co2 = np.arange(400, 2000, 5) # CO2: 400 - 2000 ppm\n",
|
||
"u_time = np.arange(0, 24, 0.25) # time of day: 0 - 24h\n",
|
||
"\n",
|
||
"# Universe of the output variable\n",
|
||
"u_power = np.arange(0, 100, 1) # power: 0 - 100%"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "f500e37a",
|
||
"metadata": {},
|
||
"source": [
|
||
"### Membership functions\n",
|
||
"\n",
|
||
"Membership functions describe to what degree a given numerical value \"belongs\" to a particular linguistic category. For example, 27°C is *partially* \"medium\" and *partially* \"high\" at the same time - there is no sharp boundary. We use trapezoidal (`trapmf`) and triangular (`trimf`) shapes, the most common choices in practice. Note the naming convention: `L`-shaped functions saturate at 1.0 on the left edge of the universe (very low values), `Γ`-shaped functions saturate on the right edge (very high values), and the functions in between cover the middle region.\n",
|
||
"\n",
|
||
"**Important**: for each input variable, the membership functions should together cover the whole universe - the sum of memberships at every point should be approximately 1.0. If it drops close to zero somewhere, we have a \"dead zone\": a region where the system cannot make a decision, because no rule fires with meaningful strength."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": null,
|
||
"id": "b754725d",
|
||
"metadata": {},
|
||
"outputs": [],
|
||
"source": [
|
||
"# ============================================================\n",
|
||
"# STEP 1.2 – Membership functions\n",
|
||
"# ============================================================\n",
|
||
"# --- TEMPERATURE (°C) ---\n",
|
||
"mf_temp_low = fuzz.trapmf(u_temp, [15, 15, 18, 23]) # L-shape\n",
|
||
"mf_temp_medium = fuzz.trapmf(u_temp, [18, 23, 25, 30]) # trapezoid\n",
|
||
"mf_temp_high = fuzz.trapmf(u_temp, [25, 30, 35, 35]) # Γ-shape\n",
|
||
"\n",
|
||
"# --- HUMIDITY (%) ---\n",
|
||
"mf_hum_dry = fuzz.trapmf(u_hum, [20, 20, 35, 55]) # L-shape\n",
|
||
"mf_hum_comfort = fuzz.trimf (u_hum, [35, 55, 75]) # triangle\n",
|
||
"mf_hum_humid = fuzz.trapmf(u_hum, [55, 75, 90, 90]) # Γ-shape\n",
|
||
"\n",
|
||
"# --- OCCUPANCY (number of people) ---\n",
|
||
"mf_occ_low = fuzz.trapmf(u_occ, [0, 0, 15, 40]) # L-shape\n",
|
||
"mf_occ_medium = fuzz.trapmf(u_occ, [15, 40, 60, 85]) # trapezoid\n",
|
||
"mf_occ_high = fuzz.trapmf(u_occ, [60, 85, 100, 100]) # Γ-shape\n",
|
||
"\n",
|
||
"# --- CO2 (ppm) ---\n",
|
||
"mf_co2_normal = fuzz.trapmf(u_co2, [400, 400, 800, 1000]) # L-shape\n",
|
||
"mf_co2_elev = fuzz.trimf (u_co2, [800, 1000, 1400]) # triangle\n",
|
||
"mf_co2_high = fuzz.trapmf(u_co2, [1000, 1400, 2000, 2000]) # Γ-shape\n",
|
||
"\n",
|
||
"# --- TIME OF DAY (h) ---\n",
|
||
"mf_time_night = fuzz.trapmf(u_time, [0, 0, 4, 6]) # L-shape\n",
|
||
"mf_time_day = fuzz.trapmf(u_time, [4, 6, 16, 20]) # trapezoid\n",
|
||
"mf_time_even = fuzz.trapmf(u_time, [16, 20, 24, 24]) # Γ-shape\n",
|
||
"\n",
|
||
"# --- FAN POWER (%) — output variable ---\n",
|
||
"mf_power_off = fuzz.trapmf(u_power, [0, 0, 10, 20]) # L-shape\n",
|
||
"mf_power_low = fuzz.trimf (u_power, [10, 20, 40]) # triangle\n",
|
||
"mf_power_medium = fuzz.trapmf(u_power, [20, 40, 60, 80]) # trapezoid\n",
|
||
"mf_power_high = fuzz.trimf (u_power, [60, 80, 95]) # triangle\n",
|
||
"mf_power_full = fuzz.trapmf(u_power, [80, 95, 100, 100]) # Γ-shape\n",
|
||
"\n",
|
||
"print(\"Universe coverage check (minimum of the summed memberships; for this exercise it should be ≈ 1.0):\")\n",
|
||
"print(f\" Temperature — min: {(mf_temp_low+mf_temp_medium+mf_temp_high).min():.3f}\")\n",
|
||
"print(f\" Humidity — min: {(mf_hum_dry+mf_hum_comfort+mf_hum_humid).min():.3f}\")\n",
|
||
"print(f\" Occupancy — min: {(mf_occ_low+mf_occ_medium+mf_occ_high).min():.3f}\")\n",
|
||
"print(f\" CO₂ — min: {(mf_co2_normal+mf_co2_elev+mf_co2_high).min():.3f}\")\n",
|
||
"print(f\" Time of day — min: {(mf_time_night+mf_time_day+mf_time_even).min():.3f}\")"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "b9e07608",
|
||
"metadata": {},
|
||
"source": [
|
||
"### Visualization of the membership functions\n",
|
||
"\n",
|
||
"One picture is worth a thousand words. Look through the plots below and pay attention to where the functions overlap — those overlapping regions are exactly what makes the logic \"fuzzy\": a value there belongs partially to two categories at once. The flat, non-overlapping parts represent situations everyone would classify the same way."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": null,
|
||
"id": "c4872897",
|
||
"metadata": {},
|
||
"outputs": [],
|
||
"source": [
|
||
"# ============================================================\n",
|
||
"# STEP 1.3 – Visualization\n",
|
||
"# ============================================================\n",
|
||
"\n",
|
||
"fig, axes = plt.subplots(3, 2, figsize=(14, 12))\n",
|
||
"fig.suptitle(\"Membership functions — Air-conditioning controller\", fontsize=14, fontweight='bold')\n",
|
||
"\n",
|
||
"# Temperature\n",
|
||
"ax = axes[0, 0]\n",
|
||
"ax.plot(u_temp, mf_temp_low, 'b-', linewidth=2, label='low')\n",
|
||
"ax.plot(u_temp, mf_temp_medium, 'g-', linewidth=2, label='medium')\n",
|
||
"ax.plot(u_temp, mf_temp_high, 'r-', linewidth=2, label='high')\n",
|
||
"ax.set_title('Temperature (°C)'); ax.set_ylabel('Membership μ')\n",
|
||
"ax.legend(); ax.grid(True, alpha=0.3); ax.set_ylim([-0.05, 1.1])\n",
|
||
"\n",
|
||
"# Humidity\n",
|
||
"ax = axes[0, 1]\n",
|
||
"ax.plot(u_hum, mf_hum_dry, 'b-', linewidth=2, label='dry')\n",
|
||
"ax.plot(u_hum, mf_hum_comfort, 'g-', linewidth=2, label='comfortable')\n",
|
||
"ax.plot(u_hum, mf_hum_humid, 'r-', linewidth=2, label='humid')\n",
|
||
"ax.set_title('Humidity (%)'); ax.legend(); ax.grid(True, alpha=0.3); ax.set_ylim([-0.05, 1.1])\n",
|
||
"\n",
|
||
"# Occupancy\n",
|
||
"ax = axes[1, 0]\n",
|
||
"ax.plot(u_occ, mf_occ_low, 'b-', linewidth=2, label='few')\n",
|
||
"ax.plot(u_occ, mf_occ_medium, 'g-', linewidth=2, label='medium')\n",
|
||
"ax.plot(u_occ, mf_occ_high, 'r-', linewidth=2, label='many')\n",
|
||
"ax.set_title('Number of people'); ax.set_ylabel('Membership μ')\n",
|
||
"ax.legend(); ax.grid(True, alpha=0.3); ax.set_ylim([-0.05, 1.1])\n",
|
||
"\n",
|
||
"# CO2\n",
|
||
"ax = axes[1, 1]\n",
|
||
"ax.plot(u_co2, mf_co2_normal, 'b-', linewidth=2, label='normal')\n",
|
||
"ax.plot(u_co2, mf_co2_elev, 'g-', linewidth=2, label='elevated')\n",
|
||
"ax.plot(u_co2, mf_co2_high, 'r-', linewidth=2, label='high')\n",
|
||
"ax.set_title('CO₂ (ppm)'); ax.legend(); ax.grid(True, alpha=0.3); ax.set_ylim([-0.05, 1.1])\n",
|
||
"\n",
|
||
"# Time of day\n",
|
||
"ax = axes[2, 0]\n",
|
||
"ax.plot(u_time, mf_time_night, 'b-', linewidth=2, label='night')\n",
|
||
"ax.plot(u_time, mf_time_day, 'g-', linewidth=2, label='day')\n",
|
||
"ax.plot(u_time, mf_time_even, 'r-', linewidth=2, label='evening')\n",
|
||
"ax.set_title('Time of day (h)'); ax.set_ylabel('Membership μ'); ax.set_xlabel('Hour')\n",
|
||
"ax.legend(); ax.grid(True, alpha=0.3); ax.set_ylim([-0.05, 1.1])\n",
|
||
"\n",
|
||
"# Cooling power\n",
|
||
"ax = axes[2, 1]\n",
|
||
"ax.plot(u_power, mf_power_off, 'navy', linewidth=2, label='off')\n",
|
||
"ax.plot(u_power, mf_power_low, 'b-', linewidth=2, label='low')\n",
|
||
"ax.plot(u_power, mf_power_medium,'g-', linewidth=2, label='medium')\n",
|
||
"ax.plot(u_power, mf_power_high, 'orange', linewidth=2, label='high')\n",
|
||
"ax.plot(u_power, mf_power_full, 'r-', linewidth=2, label='full')\n",
|
||
"ax.set_title('Cooling power (%)'); ax.set_xlabel('Power [%]')\n",
|
||
"ax.legend(); ax.grid(True, alpha=0.3); ax.set_ylim([-0.05, 1.1])\n",
|
||
"\n",
|
||
"plt.tight_layout()\n",
|
||
"plt.savefig('membership_functions.png', dpi=120, bbox_inches='tight')\n",
|
||
"plt.show()"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "eb27ec12",
|
||
"metadata": {},
|
||
"source": [
|
||
"### Universe coverage verification\n",
|
||
"\n",
|
||
"This plot shows the *sum* of all membership functions at every point of each universe. An ideal sum is 1.0 everywhere — it means the system always \"knows\" how to classify the value. Points where the sum drops below 0.5 are potential \"dead zones\" — regions where the rules barely fire and the output becomes unreliable."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": null,
|
||
"id": "93c17664",
|
||
"metadata": {},
|
||
"outputs": [],
|
||
"source": [
|
||
"# ============================================================\n",
|
||
"# STEP 1.4 – Universe coverage verification\n",
|
||
"# ============================================================\n",
|
||
"\n",
|
||
"fig, axes = plt.subplots(2, 3, figsize=(15, 8))\n",
|
||
"fig.suptitle(\"Universe coverage check — sum of membership functions\",\n",
|
||
" fontsize=13, fontweight='bold')\n",
|
||
"\n",
|
||
"variables = [\n",
|
||
" (\"Temperature (°C)\", u_temp, [mf_temp_low, mf_temp_medium, mf_temp_high],\n",
|
||
" ['low','medium','high'], ['b','g','r']),\n",
|
||
" (\"Humidity (%)\", u_hum, [mf_hum_dry, mf_hum_comfort, mf_hum_humid],\n",
|
||
" ['dry','comfort','humid'], ['b','g','r']),\n",
|
||
" (\"Number of people\", u_occ, [mf_occ_low, mf_occ_medium, mf_occ_high],\n",
|
||
" ['few','medium','many'], ['b','g','r']),\n",
|
||
" (\"CO₂ (ppm)\", u_co2, [mf_co2_normal, mf_co2_elev, mf_co2_high],\n",
|
||
" ['normal','elevated','high'], ['b','g','r']),\n",
|
||
" (\"Time of day (h)\", u_time, [mf_time_night, mf_time_day, mf_time_even],\n",
|
||
" ['night','day','evening'], ['b','g','r']),\n",
|
||
"]\n",
|
||
"\n",
|
||
"for i, (name, u, mfs, labels, colors) in enumerate(variables):\n",
|
||
" row, col = divmod(i, 3)\n",
|
||
" ax = axes[row, col]\n",
|
||
"\n",
|
||
" # Draw the individual membership functions\n",
|
||
" for mf, lab, col in zip(mfs, labels, colors):\n",
|
||
" ax.plot(u, mf, color=col, linewidth=1.5, alpha=0.6, label=lab)\n",
|
||
"\n",
|
||
" # Summed membership\n",
|
||
" total = sum(mfs)\n",
|
||
" ax.plot(u, total, 'k--', linewidth=2, label='sum')\n",
|
||
" ax.fill_between(u, total, alpha=0.1, color='gray')\n",
|
||
" ax.axhline(y=1.0, color='gray', linewidth=0.8, linestyle=':')\n",
|
||
"\n",
|
||
" # Mark dead zones (sum < 0.5) — with L/Γ functions there should be none\n",
|
||
" dead = u[total < 0.5]\n",
|
||
" if len(dead) > 0:\n",
|
||
" ax.scatter(dead, np.zeros_like(dead), color='red', s=5, zorder=5,\n",
|
||
" label='dead zone!')\n",
|
||
"\n",
|
||
" ax.set_title(name); ax.set_ylim([-0.05, 2.0])\n",
|
||
" ax.legend(fontsize=7); ax.grid(True, alpha=0.3)\n",
|
||
"\n",
|
||
"# Remove the empty sixth subplot\n",
|
||
"axes[1, 2].axis('off')\n",
|
||
"\n",
|
||
"plt.tight_layout()\n",
|
||
"plt.savefig('coverage_check.png', dpi=120, bbox_inches='tight')\n",
|
||
"plt.show()\n",
|
||
"\n",
|
||
"# Text report\n",
|
||
"print(\"UNIVERSE COVERAGE\")\n",
|
||
"print(\"=\" * 55)\n",
|
||
"print(f\" {'Variable':<22} {'min sum':>9} {'Status'}\")\n",
|
||
"print(\"-\" * 55)\n",
|
||
"for name, u, mfs, *_ in variables:\n",
|
||
" total = sum(mfs)\n",
|
||
" min_total = total.min()\n",
|
||
" n_dead = (total < 0.5).sum()\n",
|
||
" status = \"- full coverage\" if n_dead == 0 else f\"✗ DEAD ZONE ({n_dead} pts)\"\n",
|
||
" print(f\" {name:<22} {min_total:>9.3f} {status}\")"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "0158f404",
|
||
"metadata": {},
|
||
"source": [
|
||
"### Fuzzification of a single case\n",
|
||
"\n",
|
||
"Fuzzification is the first stage of fuzzy inference: converting crisp numerical inputs into degrees of membership in fuzzy sets. Analyse the outputs — a temperature of 27°C belongs partly to \"medium\" and partly to \"high\". That is the essence of fuzziness: there is no sharp border between the categories.\n",
|
||
"\n",
|
||
"Below is an **example input**: a hot evening, lots of people, poor air quality — basically any pub on a Friday night. Luckily, smoking in public places has been banned for a few years now..."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": null,
|
||
"id": "65f4adec",
|
||
"metadata": {},
|
||
"outputs": [],
|
||
"source": [
|
||
"example = {\n",
|
||
" 'temp': 27.0, # °C — warm, but not extreme\n",
|
||
" 'hum': 70.0, # % — humid\n",
|
||
" 'occ': 60.0, # — many people\n",
|
||
" 'co2': 1100.0, # ppm — elevated/high CO₂\n",
|
||
" 'time': 20.0 # h — evening\n",
|
||
"}"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": null,
|
||
"id": "547ce085",
|
||
"metadata": {},
|
||
"outputs": [],
|
||
"source": [
|
||
"# ============================================================\n",
|
||
"# STEP 1.4b – Fuzzification of a single case\n",
|
||
"# ============================================================\n",
|
||
"\n",
|
||
"# Compute membership degrees\n",
|
||
"mu_temp_low = fuzz.interp_membership(u_temp, mf_temp_low, example['temp'])\n",
|
||
"mu_temp_medium = fuzz.interp_membership(u_temp, mf_temp_medium, example['temp'])\n",
|
||
"mu_temp_high = fuzz.interp_membership(u_temp, mf_temp_high, example['temp'])\n",
|
||
"\n",
|
||
"mu_hum_dry = fuzz.interp_membership(u_hum, mf_hum_dry, example['hum'])\n",
|
||
"mu_hum_comfort = fuzz.interp_membership(u_hum, mf_hum_comfort, example['hum'])\n",
|
||
"mu_hum_humid = fuzz.interp_membership(u_hum, mf_hum_humid, example['hum'])\n",
|
||
"\n",
|
||
"mu_occ_low = fuzz.interp_membership(u_occ, mf_occ_low, example['occ'])\n",
|
||
"mu_occ_medium = fuzz.interp_membership(u_occ, mf_occ_medium, example['occ'])\n",
|
||
"mu_occ_high = fuzz.interp_membership(u_occ, mf_occ_high, example['occ'])\n",
|
||
"\n",
|
||
"mu_co2_normal = fuzz.interp_membership(u_co2, mf_co2_normal, example['co2'])\n",
|
||
"mu_co2_elev = fuzz.interp_membership(u_co2, mf_co2_elev, example['co2'])\n",
|
||
"mu_co2_high = fuzz.interp_membership(u_co2, mf_co2_high, example['co2'])\n",
|
||
"\n",
|
||
"mu_time_night = fuzz.interp_membership(u_time, mf_time_night, example['time'])\n",
|
||
"mu_time_day = fuzz.interp_membership(u_time, mf_time_day, example['time'])\n",
|
||
"mu_time_even = fuzz.interp_membership(u_time, mf_time_even, example['time'])\n",
|
||
"\n",
|
||
"# Print the fuzzification results\n",
|
||
"print(\"=\" * 55)\n",
|
||
"print(\"FUZZIFICATION — membership degrees\")\n",
|
||
"print(\"=\" * 55)\n",
|
||
"print(f\"Temperature = {example['temp']}°C\")\n",
|
||
"print(f\" μ(low) = {mu_temp_low:.4f}\")\n",
|
||
"print(f\" μ(medium) = {mu_temp_medium:.4f}\")\n",
|
||
"print(f\" μ(high) = {mu_temp_high:.4f}\")\n",
|
||
"print()\n",
|
||
"print(f\"Humidity = {example['hum']}%\")\n",
|
||
"print(f\" μ(dry) = {mu_hum_dry:.4f}\")\n",
|
||
"print(f\" μ(comfort) = {mu_hum_comfort:.4f}\")\n",
|
||
"print(f\" μ(humid) = {mu_hum_humid:.4f}\")\n",
|
||
"print()\n",
|
||
"print(f\"People = {example['occ']}\")\n",
|
||
"print(f\" μ(few) = {mu_occ_low:.4f}\")\n",
|
||
"print(f\" μ(medium) = {mu_occ_medium:.4f}\")\n",
|
||
"print(f\" μ(many) = {mu_occ_high:.4f}\")\n",
|
||
"print()\n",
|
||
"print(f\"CO₂ = {example['co2']} ppm\")\n",
|
||
"print(f\" μ(normal) = {mu_co2_normal:.4f}\")\n",
|
||
"print(f\" μ(elevated) = {mu_co2_elev:.4f}\")\n",
|
||
"print(f\" μ(high) = {mu_co2_high:.4f}\")\n",
|
||
"print()\n",
|
||
"print(f\"Time of day = {example['time']}h\")\n",
|
||
"print(f\" μ(night) = {mu_time_night:.4f}\")\n",
|
||
"print(f\" μ(day) = {mu_time_day:.4f}\")\n",
|
||
"print(f\" μ(evening) = {mu_time_even:.4f}\")\n",
|
||
"print(\"=\" * 55)\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "e7319315",
|
||
"metadata": {},
|
||
"source": [
|
||
"### Rule base (Mamdani)\n",
|
||
"\n",
|
||
"The Mamdani method is the classical approach to fuzzy inference, in which both rule antecedents and consequents are expressed as fuzzy sets. \n",
|
||
"\n",
|
||
"The inference process consists of four stages: \n",
|
||
"(1) fuzzification - converting crisp input values into membership degrees of the input fuzzy sets; \n",
|
||
"(2) rule evaluation - for each IF-THEN rule a firing strength is computed as the minimum of the membership degrees of all its conditions (the AND operator); \n",
|
||
"(3) aggregation - clipping the output membership functions at each rule's firing strength and combining all results with the maximum operator; \n",
|
||
"(4) defuzzification - converting the aggregated output set back into a single crisp value, most commonly using the centroid method (the centre of gravity of the area under the membership curve). The method's defining strength is its interpretability: every step can be traced and explained in natural language, which makes it the preferred choice for control systems built on expert knowledge.\n",
|
||
"\n",
|
||
"Fuzzy rules are the heart of the FLS. Each rule has the form IF condition THEN decision. Conditions are combined with the AND operator (implemented as the minimum of the membership degrees - the Mamdani method). Because the firing strength of a conjunctive rule equals its weakest premise, every extra condition in a rule makes the rule *more specific* and less likely to fire.\n",
|
||
"\n",
|
||
"Study rules R01 to R15. Is the logic understandable? For instance R01: \"IF it is **hot** AND **humid**, THEN fan at **full** power\" — a sensible expert decision (high humidity prevents sweat evaporation, so we feel hot at lower temperatures and need stronger airflow).\n",
|
||
"\n",
|
||
"Note the `'-'` symbol in the table: it means \"this variable does not participate in this rule\" — the rule ignores that input."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": null,
|
||
"id": "34c16b8e",
|
||
"metadata": {},
|
||
"outputs": [],
|
||
"source": [
|
||
"# ============================================================\n",
|
||
"# STEP 1.5 – Rule base (Mamdani) — table-based definition\n",
|
||
"# ============================================================\n",
|
||
"\n",
|
||
"RULE_BASE = [\n",
|
||
" # temp hum occ co2 time → power\n",
|
||
" {'temp': 'high', 'hum': 'humid', 'occ': '-', 'co2': '-', 'time': '-', 'power': 'full'}, # R01\n",
|
||
" {'temp': 'high', 'hum': '-', 'occ': 'high', 'co2': '-', 'time': '-', 'power': 'full'}, # R02\n",
|
||
" {'temp': 'high', 'hum': '-', 'occ': '-', 'co2': '-', 'time': '-', 'power': 'high'}, # R03\n",
|
||
" {'temp': 'medium', 'hum': 'humid', 'occ': '-', 'co2': '-', 'time': '-', 'power': 'high'}, # R04\n",
|
||
" {'temp': 'medium', 'hum': '-', 'occ': 'high', 'co2': '-', 'time': '-', 'power': 'medium'}, # R05\n",
|
||
" {'temp': 'medium', 'hum': '-', 'occ': '-', 'co2': '-', 'time': '-', 'power': 'medium'}, # R06\n",
|
||
" {'temp': 'low', 'hum': '-', 'occ': 'low', 'co2': '-', 'time': '-', 'power': 'off'}, # R07\n",
|
||
" {'temp': 'low', 'hum': '-', 'occ': '-', 'co2': '-', 'time': '-', 'power': 'low'}, # R08\n",
|
||
" {'temp': '-', 'hum': '-', 'occ': '-', 'co2': 'high', 'time': '-', 'power': 'high'}, # R09\n",
|
||
" {'temp': '-', 'hum': '-', 'occ': 'high', 'co2': 'elev', 'time': '-', 'power': 'medium'}, # R10\n",
|
||
" {'temp': 'medium', 'hum': '-', 'occ': '-', 'co2': '-', 'time': 'night', 'power': 'low'}, # R11\n",
|
||
" {'temp': 'low', 'hum': '-', 'occ': '-', 'co2': '-', 'time': 'night', 'power': 'off'}, # R12\n",
|
||
" {'temp': 'high', 'hum': '-', 'occ': 'high', 'co2': '-', 'time': 'even', 'power': 'full'}, # R13\n",
|
||
" {'temp': '-', 'hum': '-', 'occ': '-', 'co2': 'high', 'time': 'even', 'power': 'high'}, # R14\n",
|
||
" {'temp': 'medium', 'hum': '-', 'occ': '-', 'co2': 'normal', 'time': 'day', 'power': 'medium'}, # R15\n",
|
||
"]\n",
|
||
"\n",
|
||
"# ============================================================\n",
|
||
"# Dictionary of fuzzy sets — maps (variable, label) to data\n",
|
||
"# ============================================================\n",
|
||
"\n",
|
||
"FUZZY_SETS = {\n",
|
||
" # TEMPERATURE\n",
|
||
" ('temp', 'low'): (u_temp, mf_temp_low),\n",
|
||
" ('temp', 'medium'): (u_temp, mf_temp_medium),\n",
|
||
" ('temp', 'high'): (u_temp, mf_temp_high),\n",
|
||
" # HUMIDITY\n",
|
||
" ('hum', 'dry'): (u_hum, mf_hum_dry),\n",
|
||
" ('hum', 'comfort'): (u_hum, mf_hum_comfort),\n",
|
||
" ('hum', 'humid'): (u_hum, mf_hum_humid),\n",
|
||
" # OCCUPANCY\n",
|
||
" ('occ', 'low'): (u_occ, mf_occ_low),\n",
|
||
" ('occ', 'medium'): (u_occ, mf_occ_medium),\n",
|
||
" ('occ', 'high'): (u_occ, mf_occ_high),\n",
|
||
" # CO₂\n",
|
||
" ('co2', 'normal'): (u_co2, mf_co2_normal),\n",
|
||
" ('co2', 'elev'): (u_co2, mf_co2_elev),\n",
|
||
" ('co2', 'high'): (u_co2, mf_co2_high),\n",
|
||
" # TIME OF DAY\n",
|
||
" ('time', 'night'): (u_time, mf_time_night),\n",
|
||
" ('time', 'day'): (u_time, mf_time_day),\n",
|
||
" ('time', 'even'): (u_time, mf_time_even),\n",
|
||
"}\n",
|
||
"\n",
|
||
"INPUT_VARIABLES = ['temp', 'hum', 'occ', 'co2', 'time']\n",
|
||
"\n",
|
||
"\n",
|
||
"# ============================================================\n",
|
||
"# Inference engine — generic, interprets any rule table\n",
|
||
"# ============================================================\n",
|
||
"\n",
|
||
"def evaluate_rules(rule_base, values):\n",
|
||
" active = []\n",
|
||
"\n",
|
||
" for rule in rule_base:\n",
|
||
" strengths = []\n",
|
||
" for variable in INPUT_VARIABLES:\n",
|
||
" label = rule[variable]\n",
|
||
" if label == '-':\n",
|
||
" continue # this variable does not take part in the rule\n",
|
||
" u, mf = FUZZY_SETS[(variable, label)]\n",
|
||
" mu = fuzz.interp_membership(u, mf, values[variable])\n",
|
||
" strengths.append(mu)\n",
|
||
"\n",
|
||
" strength = min(strengths) if strengths else 0.0\n",
|
||
" active.append((strength, rule['power']))\n",
|
||
"\n",
|
||
" return active\n",
|
||
"\n",
|
||
"\n",
|
||
"# --- Print the rule table ---\n",
|
||
"print(f\"Rule base defined ({len(RULE_BASE)} rules).\")\n",
|
||
"print()\n",
|
||
"print(f\" {'Nr':<4} {'temp':<10} {'hum':<12} {'occ':<8} {'co2':<10} {'time':<10} │ {'power':<8}\")\n",
|
||
"print(\" \" + \"-\" * 68)\n",
|
||
"for i, r in enumerate(RULE_BASE, 1):\n",
|
||
" print(f\" R{i:02d} {r['temp']:<10} {r['hum']:<12} {r['occ']:<8} \"\n",
|
||
" f\"{r['co2']:<10} {r['time']:<10} │ {r['power']:<8}\")"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "0a263745",
|
||
"metadata": {},
|
||
"source": [
|
||
"### Aggregation and defuzzification (centroid method)\n",
|
||
"\n",
|
||
"Defuzzification converts the aggregated fuzzy output set back into a single crisp number. We use the centroid method (centre of gravity of the area under the aggregated membership function). Run the cell - the controller computes the fan power for the example input from Step 1.4."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": null,
|
||
"id": "fd4b194f",
|
||
"metadata": {},
|
||
"outputs": [],
|
||
"source": [
|
||
"# ============================================================\n",
|
||
"# STEP 1.6 – Aggregation and defuzzification (centroid method)\n",
|
||
"# ============================================================\n",
|
||
"\n",
|
||
"OUTPUT_SETS = {\n",
|
||
" 'off': mf_power_off,\n",
|
||
" 'low': mf_power_low,\n",
|
||
" 'medium': mf_power_medium,\n",
|
||
" 'high': mf_power_high,\n",
|
||
" 'full': mf_power_full,\n",
|
||
"}\n",
|
||
"\n",
|
||
"\n",
|
||
"def defuzzify(active_rules):\n",
|
||
"\n",
|
||
" # Aggregation (max) + defuzzification (centroid), Mamdani style.\n",
|
||
" # Always returns a tuple (numeric_result, aggregated_set).\n",
|
||
"\n",
|
||
" aggregate = np.zeros_like(u_power, dtype=float)\n",
|
||
"\n",
|
||
" for strength, label in active_rules:\n",
|
||
" if strength > 0:\n",
|
||
" # Clip the output membership function at the rule's firing strength\n",
|
||
" clipped = np.fmin(strength, OUTPUT_SETS[label])\n",
|
||
" # Aggregation: max over all rules\n",
|
||
" aggregate = np.fmax(aggregate, clipped)\n",
|
||
"\n",
|
||
" # Centroid defuzzification\n",
|
||
" if np.sum(aggregate) == 0:\n",
|
||
" return 0.0, aggregate\n",
|
||
" result = fuzz.defuzz(u_power, aggregate, 'centroid')\n",
|
||
" return result, aggregate\n",
|
||
"\n",
|
||
"\n",
|
||
"def fls_controller(temp, hum, occ, co2, time, base=None):\n",
|
||
"\n",
|
||
" # Complete FLS controller: fuzzification → rules → defuzzification.\n",
|
||
" #\n",
|
||
" # The `base` parameter allows passing a different rule table\n",
|
||
" # (e.g. with a fallback rule). Defaults to RULE_BASE.\n",
|
||
"\n",
|
||
" if base is None:\n",
|
||
" base = RULE_BASE\n",
|
||
"\n",
|
||
" values = {'temp': temp, 'hum': hum, 'occ': occ, 'co2': co2, 'time': time}\n",
|
||
" active = evaluate_rules(base, values)\n",
|
||
" result, aggregate = defuzzify(active)\n",
|
||
" return result, aggregate\n",
|
||
"\n",
|
||
"\n",
|
||
"# Test the controller on the example case\n",
|
||
"result_fls, aggregate_fls = fls_controller(\n",
|
||
" temp=example['temp'],\n",
|
||
" hum=example['hum'],\n",
|
||
" occ=example['occ'],\n",
|
||
" co2=example['co2'],\n",
|
||
" time=example['time']\n",
|
||
")\n",
|
||
"\n",
|
||
"print(f\"Input: temp={example['temp']}°C, hum={example['hum']}%,\",\n",
|
||
" f\"people={example['occ']}, CO₂={example['co2']} ppm, time={example['time']}h\")\n",
|
||
"print(f\"FLS output: cooling power = {result_fls:.2f}%\")\n",
|
||
"\n",
|
||
"# Visualize the aggregate and the defuzzification result\n",
|
||
"fig, ax = plt.subplots(figsize=(8, 4))\n",
|
||
"ax.fill_between(u_power, aggregate_fls, alpha=0.4, color='steelblue', label='aggregated set')\n",
|
||
"ax.axvline(x=result_fls, color='red', linewidth=2, linestyle='--',\n",
|
||
" label=f'centroid = {result_fls:.1f}%')\n",
|
||
"ax.set_title(\"Defuzzification — FLS output passed to the actuator\")\n",
|
||
"ax.set_xlabel(\"Fan power [%]\")\n",
|
||
"ax.set_ylabel(\"Membership μ\")\n",
|
||
"ax.legend(); ax.grid(True, alpha=0.3)\n",
|
||
"plt.tight_layout()\n",
|
||
"plt.show()"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "df0dcaa1",
|
||
"metadata": {},
|
||
"source": [
|
||
"#### Questions\n",
|
||
"\n",
|
||
"Please answer the questions below.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"**1. Test the FLS controller on two input sets of your own choosing (e.g. a cool morning with few people vs. a hot afternoon with a full room). Record the results below:**\n",
|
||
"\n",
|
||
"| Temperature | Humidity | People | $CO_2$ | Time of day | FLS Power |\n",
|
||
"|---|---|---|---|---|---|\n",
|
||
"| | | | | | |\n",
|
||
"| | | | | | |\n",
|
||
"\n",
|
||
"Are the results consistent with intuition? Please justify briefly:\n",
|
||
"\n",
|
||
"*(your answer here)*\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"**2. Imagine the temperature sensor has been replaced with a new model that systematically reads 2°C higher than the actual temperature. How would you fix the FLS so it keeps working correctly? What exactly needs to be changed?**\n",
|
||
"\n",
|
||
"*(your answer here)*\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "7d3050c1-2333-4d95-a60c-2c4d3c9e09c0",
|
||
"metadata": {},
|
||
"source": [
|
||
"---\n",
|
||
"\n",
|
||
"## Experiment 2 - ANN as an FLS imitator\n",
|
||
"\n",
|
||
"**Goal**: Generate data from the working FLS (input → power), then train a simple neural network to reproduce the same mapping. Finally, compare both approaches.\n",
|
||
"\n",
|
||
"Key question to keep in mind throughout this section: if the ANN gives the same results as the FLS, why bother building the FLS at all?\n",
|
||
"\n",
|
||
"### Generating training data from the FLS\n",
|
||
"\n",
|
||
"We sample thousands of random input combinations and compute the FLS answer for each. These (input, power) pairs become the training data for the neural network. Note how cheap data generation is here - the FLS involves no physical process, just rule evaluation."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": null,
|
||
"id": "a44fa289",
|
||
"metadata": {},
|
||
"outputs": [],
|
||
"source": [
|
||
"# ============================================================\n",
|
||
"# STEP 2.1 – Generate training data from the FLS\n",
|
||
"# ============================================================\n",
|
||
"\n",
|
||
"N_SAMPLES_FLS = 10000\n",
|
||
"\n",
|
||
"rng_fls = np.random.default_rng(42)\n",
|
||
"\n",
|
||
"data_X_fls = []\n",
|
||
"data_y_fls = []\n",
|
||
"\n",
|
||
"print(f\"Generating {N_SAMPLES_FLS} samples from the FLS...\")\n",
|
||
"\n",
|
||
"for i in range(N_SAMPLES_FLS):\n",
|
||
" t = rng_fls.uniform(15, 35)\n",
|
||
" w = rng_fls.uniform(20, 90)\n",
|
||
" o = rng_fls.uniform(0, 100)\n",
|
||
" c = rng_fls.uniform(400, 2000)\n",
|
||
" p = rng_fls.uniform(0, 24)\n",
|
||
"\n",
|
||
" try:\n",
|
||
" power, _ = fls_controller(t, w, o, c, p)\n",
|
||
" except Exception:\n",
|
||
" continue\n",
|
||
"\n",
|
||
" data_X_fls.append([t, w, o, c, p])\n",
|
||
" data_y_fls.append(power)\n",
|
||
"\n",
|
||
"data_X_fls = np.array(data_X_fls)\n",
|
||
"data_y_fls = np.array(data_y_fls).reshape(-1, 1)\n",
|
||
"\n",
|
||
"print(f\"Generated {len(data_X_fls)} samples.\")\n",
|
||
"print(f\"FLS power range: {data_y_fls.min():.1f}% - {data_y_fls.max():.1f}%\")"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "4d6fb328",
|
||
"metadata": {},
|
||
"source": [
|
||
"### Normalization, data splitting, training the ANN-imitator\n",
|
||
"\n",
|
||
"We normalize the data to the [0, 1] range (MinMaxScaler), split it into train/validation/test sets, then train a simple 5→32→16→1 network. The sigmoid output naturally matches the normalized target range."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": null,
|
||
"id": "4bd6e74c",
|
||
"metadata": {
|
||
"lines_to_next_cell": 2
|
||
},
|
||
"outputs": [],
|
||
"source": [
|
||
"# ============================================================\n",
|
||
"# STEP 2.2 – Normalization and splitting\n",
|
||
"# ============================================================\n",
|
||
"\n",
|
||
"scaler_X_fls = MinMaxScaler()\n",
|
||
"scaler_y_fls = MinMaxScaler()\n",
|
||
"\n",
|
||
"X_fls_norm = scaler_X_fls.fit_transform(data_X_fls)\n",
|
||
"y_fls_norm = scaler_y_fls.fit_transform(data_y_fls)\n",
|
||
"\n",
|
||
"X_tr_f, X_te_f, y_tr_f, y_te_f = train_test_split(\n",
|
||
" X_fls_norm, y_fls_norm, test_size=0.15, random_state=42)\n",
|
||
"X_tr_f, X_val_f, y_tr_f, y_val_f = train_test_split(\n",
|
||
" X_tr_f, y_tr_f, test_size=0.176, random_state=42)\n",
|
||
"\n",
|
||
"print(f\"Train: {len(X_tr_f)}, Validation: {len(X_val_f)}, Test: {len(X_te_f)}\")\n",
|
||
"\n",
|
||
"# Tensors\n",
|
||
"X_tr_ft = torch.FloatTensor(X_tr_f)\n",
|
||
"y_tr_ft = torch.FloatTensor(y_tr_f)\n",
|
||
"X_val_ft = torch.FloatTensor(X_val_f)\n",
|
||
"y_val_ft = torch.FloatTensor(y_val_f)\n",
|
||
"X_te_ft = torch.FloatTensor(X_te_f)\n",
|
||
"y_te_ft = torch.FloatTensor(y_te_f)\n",
|
||
"\n",
|
||
"train_ds_fls = TensorDataset(X_tr_ft, y_tr_ft)\n",
|
||
"train_ldr_fls = DataLoader(train_ds_fls, batch_size=64, shuffle=True)"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": null,
|
||
"id": "c652828a",
|
||
"metadata": {},
|
||
"outputs": [],
|
||
"source": [
|
||
"# ============================================================\n",
|
||
"# STEP 2.3 – Architecture and training of the ANN-imitator\n",
|
||
"# ============================================================\n",
|
||
"\n",
|
||
"class FLSImitator(nn.Module):\n",
|
||
"\n",
|
||
" # Simple neural network imitating the FLS controller.\n",
|
||
" # Input (5): [temp, hum, occ, co2, time]\n",
|
||
" # Output (1): fan power [0–1] (normalized)\n",
|
||
"\n",
|
||
" def __init__(self):\n",
|
||
" super(FLSImitator, self).__init__()\n",
|
||
" self.net = nn.Sequential(\n",
|
||
" nn.Linear(5, 32),\n",
|
||
" nn.ReLU(),\n",
|
||
" nn.Linear(32, 16),\n",
|
||
" nn.ReLU(),\n",
|
||
" nn.Linear(16, 1),\n",
|
||
" nn.Sigmoid()\n",
|
||
" )\n",
|
||
"\n",
|
||
" def forward(self, x):\n",
|
||
" return self.net(x)\n",
|
||
"\n",
|
||
"\n",
|
||
"imitator = FLSImitator()\n",
|
||
"criterion_fls = nn.MSELoss()\n",
|
||
"optimizer_fls = torch.optim.Adam(imitator.parameters(), lr=0.001)\n",
|
||
"\n",
|
||
"N_EPOCHS_FLS = 80\n",
|
||
"history_tr_fls = []\n",
|
||
"history_val_fls = []\n",
|
||
"\n",
|
||
"print(f\"Training the ANN-imitator for {N_EPOCHS_FLS} epochs...\")\n",
|
||
"print(\"-\" * 50)\n",
|
||
"\n",
|
||
"for epoch in range(1, N_EPOCHS_FLS + 1):\n",
|
||
" imitator.train()\n",
|
||
" loss_tr = 0.0\n",
|
||
" for Xb, yb in train_ldr_fls:\n",
|
||
" optimizer_fls.zero_grad()\n",
|
||
" pred = imitator(Xb)\n",
|
||
" loss = criterion_fls(pred, yb)\n",
|
||
" loss.backward()\n",
|
||
" optimizer_fls.step()\n",
|
||
" loss_tr += loss.item() * len(Xb)\n",
|
||
" loss_tr /= len(X_tr_f)\n",
|
||
"\n",
|
||
" imitator.eval()\n",
|
||
" with torch.no_grad():\n",
|
||
" loss_val = criterion_fls(imitator(X_val_ft), y_val_ft).item()\n",
|
||
"\n",
|
||
" history_tr_fls.append(loss_tr)\n",
|
||
" history_val_fls.append(loss_val)\n",
|
||
"\n",
|
||
" if epoch % 20 == 0:\n",
|
||
" print(f\" Epoch {epoch:3d}/{N_EPOCHS_FLS} | \"\n",
|
||
" f\"train: {loss_tr:.6f} | val: {loss_val:.6f}\")\n",
|
||
"\n",
|
||
"print(\"-\" * 50)\n",
|
||
"print(\"Training finished.\")\n",
|
||
"\n",
|
||
"# Learning curve\n",
|
||
"fig, ax = plt.subplots(figsize=(9, 4))\n",
|
||
"ax.plot(history_tr_fls, label='Train', color='steelblue', linewidth=2)\n",
|
||
"ax.plot(history_val_fls, label='Validation', color='orange', linewidth=2)\n",
|
||
"ax.set_title(\"Learning curve — ANN-imitator of the FLS\")\n",
|
||
"ax.set_xlabel('Epoch'); ax.set_ylabel('MSE')\n",
|
||
"ax.set_yscale('log'); ax.legend(); ax.grid(True, alpha=0.3)\n",
|
||
"plt.tight_layout()\n",
|
||
"plt.show()"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "e22b6c11",
|
||
"metadata": {},
|
||
"source": [
|
||
"### Comparing the FLS vs the ANN-imitator\n",
|
||
"\n",
|
||
"We compare the FLS and ANN responses on the test set. A perfect imitator would give identical results — in practice there will be small differences. On the scatter plot, points should lie on the diagonal line; systematic deviations reveal regions where the network fails to reproduce the rule-based mapping."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": null,
|
||
"id": "77611efd",
|
||
"metadata": {},
|
||
"outputs": [],
|
||
"source": [
|
||
"# ============================================================\n",
|
||
"# STEP 2.4 – FLS vs ANN-imitator comparison\n",
|
||
"# ============================================================\n",
|
||
"\n",
|
||
"imitator.eval()\n",
|
||
"with torch.no_grad():\n",
|
||
" y_pred_ann = imitator(X_te_ft).numpy()\n",
|
||
"\n",
|
||
"# De-normalization\n",
|
||
"y_pred_ann_real = scaler_y_fls.inverse_transform(y_pred_ann)\n",
|
||
"y_true_real = scaler_y_fls.inverse_transform(y_te_f)\n",
|
||
"\n",
|
||
"mae_imitator = mean_absolute_error(y_true_real, y_pred_ann_real)\n",
|
||
"mse_imitator = mean_squared_error(y_true_real, y_pred_ann_real)\n",
|
||
"\n",
|
||
"print(f\"FLS vs ANN-imitator comparison on the test set:\")\n",
|
||
"print(f\" MAE = {mae_imitator:.2f}%\")\n",
|
||
"print(f\" MSE = {mse_imitator:.2f}\")\n",
|
||
"print(f\" RMSE = {np.sqrt(mse_imitator):.2f}%\")\n",
|
||
"\n",
|
||
"# Comparison plot\n",
|
||
"fig, ax = plt.subplots(figsize=(7, 7))\n",
|
||
"ax.scatter(y_true_real, y_pred_ann_real, alpha=0.3, s=8, color='steelblue')\n",
|
||
"ax.plot([0, 100], [0, 100], 'r--', linewidth=2)\n",
|
||
"ax.set_xlabel(\"Power according to FLS [%]\"); ax.set_ylabel(\"Power according to ANN [%]\")\n",
|
||
"ax.set_title(f\"FLS vs ANN-imitator (MAE={mae_imitator:.2f}%)\")\n",
|
||
"ax.grid(True, alpha=0.3)\n",
|
||
"ax.set_xlim([0, 100]); ax.set_ylim([0, 100])\n",
|
||
"plt.tight_layout()\n",
|
||
"plt.show()"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "420d98c4",
|
||
"metadata": {},
|
||
"source": [
|
||
"#### Question\n",
|
||
"\n",
|
||
"Please answer the question below.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"**3. Suppose the temperature sensor has been replaced (as in question 2). What steps are needed to repair the system:**\n",
|
||
"\n",
|
||
"a) using the FLS?\n",
|
||
"\n",
|
||
"b) using the ANN-imitator?\n",
|
||
"\n",
|
||
"*(your answer here)*\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"## Adding a fallback (default) rule and its impact on the ANN\n",
|
||
"\n",
|
||
"Our FLS has 15 rules, but the full combination space is $3^5 = 243$ (5 variables × 3 labels). There are therefore scenarios in which no rule fires strongly, the aggregate is close to zero, and the centroid result is essentially arbitrary noise. An ANN trained on such data learns that noise.\n",
|
||
"\n",
|
||
"Goal: add a single fallback (default) rule to the rule table, generate data from both FLS versions, train an ANN-imitator on each dataset, and check whether a better rule base means a better ANN.\n",
|
||
"\n",
|
||
"### Defining the FLS with a fallback rule\n",
|
||
"\n",
|
||
"A fallback rule is a table row in which every input variable is `'-'` (any value), with a constant activation strength of 0.3. In regions covered by other rules, those rules dominate (they have higher firing strength). In uncovered regions, the fallback provides a sensible baseline value instead of noise.\n",
|
||
"\n",
|
||
"Notice how easy this is thanks to the table-based definition — you only need to append one row."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": null,
|
||
"id": "39e99dfc",
|
||
"metadata": {},
|
||
"outputs": [],
|
||
"source": [
|
||
"# ============================================================\n",
|
||
"# STEP 2.5A – FLS with a fallback rule\n",
|
||
"# ============================================================\n",
|
||
"\n",
|
||
"# New table = original + one row.\n",
|
||
"# The '_strength' field enforces a constant activation strength\n",
|
||
"# (the inference engine checks it before computing the min).\n",
|
||
"RULE_BASE_FALLBACK = RULE_BASE + [\n",
|
||
" {'temp': '-', 'hum': '-', 'occ': '-', 'co2': '-', 'time': '-',\n",
|
||
" 'power': 'medium', '_strength': 0.3}, # R00 — FALLBACK\n",
|
||
"]\n",
|
||
"\n",
|
||
"\n",
|
||
"def evaluate_rules_v2(rule_base, values):\n",
|
||
" # Extended inference engine — handles the optional '_strength' field\n",
|
||
" # that forces a constant rule activation strength (for fallbacks).\n",
|
||
" active = []\n",
|
||
" for rule in rule_base:\n",
|
||
" if '_strength' in rule:\n",
|
||
" active.append((rule['_strength'], rule['power']))\n",
|
||
" continue\n",
|
||
"\n",
|
||
" strengths = []\n",
|
||
" for variable in INPUT_VARIABLES:\n",
|
||
" label = rule[variable]\n",
|
||
" if label == '-':\n",
|
||
" continue\n",
|
||
" u, mf = FUZZY_SETS[(variable, label)]\n",
|
||
" mu = fuzz.interp_membership(u, mf, values[variable])\n",
|
||
" strengths.append(mu)\n",
|
||
"\n",
|
||
" strength = min(strengths) if strengths else 0.0\n",
|
||
" active.append((strength, rule['power']))\n",
|
||
" return active\n",
|
||
"\n",
|
||
"\n",
|
||
"def fls_controller_fallback(temp, hum, occ, co2, time):\n",
|
||
" values = {'temp': temp, 'hum': hum, 'occ': occ, 'co2': co2, 'time': time}\n",
|
||
" active = evaluate_rules_v2(RULE_BASE_FALLBACK, values)\n",
|
||
" return defuzzify(active)\n",
|
||
"\n",
|
||
"\n",
|
||
"# --- Quick test: comparison on several random scenarios ---\n",
|
||
"rng_diag = np.random.default_rng(99)\n",
|
||
"print(\"Comparison: FLS vs FLS+fallback (10 random scenarios):\")\n",
|
||
"print(\"-\" * 72)\n",
|
||
"print(f\" {'temp':>5} {'hum':>5} {'occ':>5} {'co2':>6} {'time':>5} │ \"\n",
|
||
" f\"{'FLS':>7} {'FLS+fb':>7} {'Δ':>6} {'max μ':>6}\")\n",
|
||
"print(\"-\" * 72)\n",
|
||
"for _ in range(10):\n",
|
||
" t = rng_diag.uniform(15, 35)\n",
|
||
" w = rng_diag.uniform(20, 90)\n",
|
||
" o = rng_diag.uniform(0, 100)\n",
|
||
" c = rng_diag.uniform(400, 2000)\n",
|
||
" p = rng_diag.uniform(0, 24)\n",
|
||
" power_orig, agg_orig = fls_controller(t, w, o, c, p)\n",
|
||
" power_fb, _ = fls_controller_fallback(t, w, o, c, p)\n",
|
||
" delta = power_fb - power_orig\n",
|
||
" max_mu = np.max(agg_orig)\n",
|
||
" print(f\" {t:5.1f} {w:5.1f} {o:5.1f} {c:6.0f} {p:5.1f} │ \"\n",
|
||
" f\"{power_orig:7.1f} {power_fb:7.1f} {delta:+6.1f} {max_mu:6.3f}\")"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "f3f59456",
|
||
"metadata": {},
|
||
"source": [
|
||
"Δ ≈ 0 → the rules covered this scenario (the fallback changed nothing)\n",
|
||
"\n",
|
||
"Δ ≠ 0 → an uncovered scenario — the fallback corrected the response\n",
|
||
"\n",
|
||
"max μ < 0.15 → the aggregate was almost empty (the FLS \"didn't know what to do\")\n",
|
||
"\n",
|
||
"### Training an ANN on data from both FLS versions and comparing\n",
|
||
"\n",
|
||
"Now the key exercise — we generate training data twice:\n",
|
||
"- once from the original FLS (15 rules),\n",
|
||
"- once from the FLS+fallback (16 rules).\n",
|
||
"\n",
|
||
"On each dataset we train an identical ANN-imitator. We then compare the metrics and plots.\n",
|
||
"\n",
|
||
"**Hypothesis to verify**: the ANN trained on data from the FLS+fallback should achieve a lower error (MAE), because its training data contains no noise in the uncovered regions."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": null,
|
||
"id": "0a6523cf",
|
||
"metadata": {
|
||
"lines_to_next_cell": 2
|
||
},
|
||
"outputs": [],
|
||
"source": [
|
||
"# ============================================================\n",
|
||
"# STEP 2.5B – Comparing ANNs trained on FLS vs FLS+fallback data\n",
|
||
"# ============================================================\n",
|
||
"\n",
|
||
"def generate_fls_data(controller_fn, n_samples, seed):\n",
|
||
" rng = np.random.default_rng(seed)\n",
|
||
" X_list, y_list = [], []\n",
|
||
" for _ in range(n_samples):\n",
|
||
" t = rng.uniform(15, 35)\n",
|
||
" w = rng.uniform(20, 90)\n",
|
||
" o = rng.uniform(0, 100)\n",
|
||
" c = rng.uniform(400, 2000)\n",
|
||
" p = rng.uniform(0, 24)\n",
|
||
" try:\n",
|
||
" power, _ = controller_fn(t, w, o, c, p)\n",
|
||
" except Exception:\n",
|
||
" continue\n",
|
||
" X_list.append([t, w, o, c, p])\n",
|
||
" y_list.append(power)\n",
|
||
" return np.array(X_list), np.array(y_list).reshape(-1, 1)\n",
|
||
"\n",
|
||
"\n",
|
||
"def train_imitator(X_raw, y_raw, n_epochs=80, seed=42):\n",
|
||
" scX = MinMaxScaler()\n",
|
||
" scY = MinMaxScaler()\n",
|
||
" Xn = scX.fit_transform(X_raw)\n",
|
||
" yn = scY.fit_transform(y_raw)\n",
|
||
"\n",
|
||
" Xtr, Xte, ytr, yte = train_test_split(Xn, yn, test_size=0.15, random_state=seed)\n",
|
||
" Xtr, Xval, ytr, yval = train_test_split(Xtr, ytr, test_size=0.176, random_state=seed)\n",
|
||
"\n",
|
||
" ds = TensorDataset(torch.FloatTensor(Xtr), torch.FloatTensor(ytr))\n",
|
||
" ldr = DataLoader(ds, batch_size=64, shuffle=True)\n",
|
||
"\n",
|
||
" torch.manual_seed(seed)\n",
|
||
" model = FLSImitator()\n",
|
||
" opt = torch.optim.Adam(model.parameters(), lr=0.001)\n",
|
||
" crit = nn.MSELoss()\n",
|
||
"\n",
|
||
" hist_tr, hist_val = [], []\n",
|
||
" for ep in range(1, n_epochs + 1):\n",
|
||
" model.train()\n",
|
||
" s = 0.0\n",
|
||
" for Xb, yb in ldr:\n",
|
||
" opt.zero_grad()\n",
|
||
" loss = crit(model(Xb), yb)\n",
|
||
" loss.backward()\n",
|
||
" opt.step()\n",
|
||
" s += loss.item() * len(Xb)\n",
|
||
" hist_tr.append(s / len(Xtr))\n",
|
||
" model.eval()\n",
|
||
" with torch.no_grad():\n",
|
||
" hist_val.append(crit(model(torch.FloatTensor(Xval)),\n",
|
||
" torch.FloatTensor(yval)).item())\n",
|
||
"\n",
|
||
" # Evaluation on the test set\n",
|
||
" model.eval()\n",
|
||
" with torch.no_grad():\n",
|
||
" pred = model(torch.FloatTensor(Xte)).numpy()\n",
|
||
" y_pred_real = scY.inverse_transform(pred)\n",
|
||
" y_true_real = scY.inverse_transform(yte)\n",
|
||
"\n",
|
||
" return {\n",
|
||
" 'model': model, 'scX': scX, 'scY': scY,\n",
|
||
" 'y_pred': y_pred_real, 'y_true': y_true_real,\n",
|
||
" 'hist_tr': hist_tr, 'hist_val': hist_val,\n",
|
||
" 'mae': mean_absolute_error(y_true_real, y_pred_real),\n",
|
||
" 'rmse': np.sqrt(mean_squared_error(y_true_real, y_pred_real)),\n",
|
||
" }\n",
|
||
"\n",
|
||
"\n",
|
||
"# --- Generate data and train ---\n",
|
||
"N_SAMPLES = 10000\n",
|
||
"\n",
|
||
"print(\"Generating data from the FLS (15 rules)...\")\n",
|
||
"X_orig, y_orig = generate_fls_data(fls_controller, N_SAMPLES, seed=42)\n",
|
||
"\n",
|
||
"print(\"Generating data from the FLS+fallback (16 rules)...\")\n",
|
||
"X_fb, y_fb = generate_fls_data(fls_controller_fallback, N_SAMPLES, seed=42)\n",
|
||
"\n",
|
||
"print(\"\\nTraining the ANN-imitator on FLS data...\")\n",
|
||
"results_orig = train_imitator(X_orig, y_orig)\n",
|
||
"\n",
|
||
"print(\"Training the ANN-imitator on FLS+fallback data...\")\n",
|
||
"results_fb = train_imitator(X_fb, y_fb)\n",
|
||
"\n",
|
||
"# --- Report ---\n",
|
||
"print()\n",
|
||
"print(\"=\" * 55)\n",
|
||
"print(\"COMPARISON: ANN trained on FLS vs FLS+fallback data\")\n",
|
||
"print(\"=\" * 55)\n",
|
||
"print(f\" {'Data source':<28} {'MAE':>8} {'RMSE':>8}\")\n",
|
||
"print(\"-\" * 55)\n",
|
||
"print(f\" {'FLS (15 rules)':<28} {results_orig['mae']:8.2f}% {results_orig['rmse']:8.2f}%\")\n",
|
||
"print(f\" {'FLS + fallback (16 rules)':<28} {results_fb['mae']:8.2f}% {results_fb['rmse']:8.2f}%\")\n",
|
||
"print(\"=\" * 55)"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": null,
|
||
"id": "e79259cb",
|
||
"metadata": {},
|
||
"outputs": [],
|
||
"source": [
|
||
"# --- Comparative visualization ---\n",
|
||
"fig, axes = plt.subplots(1, 2, figsize=(14, 6))\n",
|
||
"\n",
|
||
"for i, (name, result, color) in enumerate([\n",
|
||
" ('ANN trained on FLS (15 rules)', results_orig, 'steelblue'),\n",
|
||
" ('ANN trained on FLS+fallback (16)', results_fb, 'darkorange'),\n",
|
||
"]):\n",
|
||
" ax = axes[i]\n",
|
||
" ax.scatter(result['y_true'], result['y_pred'], alpha=0.3, s=8, color=color)\n",
|
||
" ax.plot([0, 100], [0, 100], 'r--', linewidth=2)\n",
|
||
" ax.set_xlabel(\"Power according to FLS [%]\"); ax.set_ylabel(\"Power according to ANN [%]\")\n",
|
||
" ax.set_title(f\"{name}\\nMAE = {result['mae']:.2f}%\", fontsize=11)\n",
|
||
" ax.grid(True, alpha=0.3)\n",
|
||
" ax.set_xlim([0, 100]); ax.set_ylim([0, 100])\n",
|
||
"\n",
|
||
"plt.suptitle(\"Impact of FLS data quality on the ANN-imitator\", fontsize=13, fontweight='bold')\n",
|
||
"plt.tight_layout()\n",
|
||
"plt.savefig('comparison_fls_vs_fallback_ann.png', dpi=120, bbox_inches='tight')\n",
|
||
"plt.show()\n",
|
||
"\n",
|
||
"# Learning curves side by side\n",
|
||
"fig, axes = plt.subplots(1, 2, figsize=(14, 4))\n",
|
||
"for i, (name, result, color) in enumerate([\n",
|
||
" ('FLS (15 rules)', results_orig, 'steelblue'),\n",
|
||
" ('FLS+fallback (16)', results_fb, 'darkorange'),\n",
|
||
"]):\n",
|
||
" ax = axes[i]\n",
|
||
" ax.plot(result['hist_tr'], label='Train', color=color, linewidth=2)\n",
|
||
" ax.plot(result['hist_val'], label='Validation', color='gray', linewidth=2, linestyle='--')\n",
|
||
" ax.set_title(f\"Learning curve — {name}\")\n",
|
||
" ax.set_xlabel('Epoch'); ax.set_ylabel('MSE')\n",
|
||
" ax.set_yscale('log'); ax.legend(); ax.grid(True, alpha=0.3)\n",
|
||
"\n",
|
||
"plt.tight_layout()\n",
|
||
"plt.show()"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "8a43291f",
|
||
"metadata": {},
|
||
"source": [
|
||
"#### Question\n",
|
||
"\n",
|
||
"Please answer the question below.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"**4. Why can't the ANN itself \"repair\" the gaps in the FLS rules?**\n",
|
||
"\n",
|
||
"*(your answer here)*\n",
|
||
"\n",
|
||
"---"
|
||
]
|
||
}
|
||
],
|
||
"metadata": {
|
||
"jupytext": {
|
||
"cell_metadata_filter": "-all",
|
||
"main_language": "python",
|
||
"notebook_metadata_filter": "-all"
|
||
},
|
||
"kernelspec": {
|
||
"display_name": "Python 3 (ipykernel)",
|
||
"language": "python",
|
||
"name": "python3"
|
||
},
|
||
"language_info": {
|
||
"codemirror_mode": {
|
||
"name": "ipython",
|
||
"version": 3
|
||
},
|
||
"file_extension": ".py",
|
||
"mimetype": "text/x-python",
|
||
"name": "python",
|
||
"nbconvert_exporter": "python",
|
||
"pygments_lexer": "ipython3",
|
||
"version": "3.10.16"
|
||
}
|
||
},
|
||
"nbformat": 4,
|
||
"nbformat_minor": 5
|
||
}
|