diff --git a/week-3/.gitignore b/week-3/.gitignore
new file mode 100644
index 0000000..96026b4
--- /dev/null
+++ b/week-3/.gitignore
@@ -0,0 +1,30 @@
+# Python Bytecode and Caches
+__pycache__/
+*.py[cod]
+*$py.class
+*.egg-info/
+.pytest_cache/
+
+# Virtual Environments
+.venv/
+venv/
+env/
+
+# Ray temp directories
+tmp_ray/
+
+# Dataset files (re-used from week-2/data or downloaded)
+data/
+MNIST/
+
+# Experiment results logs and plots
+iid_results.csv
+noniid_results.csv
+convergence_comparison.png
+*.pt
+*.pth
+
+# Editor and OS-specific files
+.vscode/
+.idea/
+.DS_Store
diff --git a/week-3/MEMO.md b/week-3/MEMO.md
new file mode 100644
index 0000000..dbcef87
--- /dev/null
+++ b/week-3/MEMO.md
@@ -0,0 +1,29 @@
+# Research Memo: The Impact of Non-IID Data on Federated Learning Convergence
+
+## 1. Expectations
+Before running the experiment, I expected that partitioning the MNIST dataset using a Dirichlet distribution ($\alpha = 0.5$) would significantly slow down model convergence and degrade the final accuracy of Federated Averaging (FedAvg) compared to an IID split, due to the statistical misalignment of local client updates.
+
+---
+
+## 2. Experimental Observations
+After executing 10 communication rounds across 5 clients, I observed a clear convergence penalty under the Non-IID data distribution, which is most prominent in the early phases of training:
+
+* **Initial Round Contrast:** In Round 1, the global model trained on IID partitions achieved a weighted test accuracy of **78.25%** with a loss of **1.2382**. In contrast, the model trained on the Non-IID Dirichlet split reached only **56.60%** accuracy with a much higher loss of **1.5654**—representing a performance gap of **21.65 percentage points**.
+* **Intermediate Convergence Rate:** The IID simulation climbed steadily and reached over 88% accuracy by Round 4 (**88.49%**), whereas the Non-IID simulation lagged behind, requiring 6 rounds to cross the 87% threshold (**87.25%**).
+* **Terminal Convergence Point:** By Round 10, the performance gap narrowed, but a clear deficit remained. The IID model finished at **90.95%** accuracy (loss: **0.3209**), while the Non-IID model reached **89.44%** accuracy (loss: **0.3677**).
+
+The resulting plot (`convergence_comparison.png`) visually overlays these trends, showing that the Non-IID curve stays consistently below the IID curve, validating the statistical heterogeneity penalty.
+
+---
+
+## 3. Underhood Mechanism: Client Drift
+The difference in convergence rates is explained by the mechanism of **client drift** (or objective inconsistency):
+
+In the IID setup, every client has a representative slice of all 10 digit classes. Therefore, each client's local loss function $F_k(w)$ is a close approximation of the global loss function $f(w)$. During local training, the gradient descent updates of all 5 clients point toward a similar global minimum, meaning their parameter vectors move in aligned directions.
+
+In the Non-IID setup, the Dirichlet split ($\alpha=0.5$) creates highly skewed class distributions. For example, Client 2 only received 6,138 samples, while Client 3 received 14,830, with certain digits heavily concentrated on specific devices. As a result, each client optimizes its local parameters $w^k$ toward its own biased local minimum $\arg\min F_k(w)$. During the local training epoch, their weights drift in divergent directions in the parameter space. When the server averages these drifted weights, the updates pull against each other. The resulting average represents a compromised model that resolves these conflicting gradients but experiences slower, less stable progress toward the global optimum.
+
+---
+
+## 4. Open Question
+This experiment raises an important question: **How does the number of local epochs ($E$) affect the magnitude of client drift?** Specifically, while increasing local epochs reduces communication frequency, does it allow client weights to drift further apart, eventually reaching a point where averaging them becomes counterproductive or even degrades global model performance?
diff --git a/week-3/README.md b/week-3/README.md
new file mode 100644
index 0000000..361b3d0
--- /dev/null
+++ b/week-3/README.md
@@ -0,0 +1,128 @@
+# Decentralized Training Fellowship: Week 3
+## The Non-IID Problem & Client Drift
+
+Welcome to Week 3 of the Decentralized Training Fellowship! This week focuses on the single most researched problem in Federated Learning: **Non-IID data**.
+
+In the real world, the assumption that client devices have independent and identically distributed (IID) samples breaks immediately. A client's local dataset represents only its own unique history and usage patterns. When clients optimize their local models on very different data distributions, their parameters diverge. This is known as **client drift**, and it is the main cause of degraded accuracy and slower convergence in federated optimization.
+
+---
+
+## 1. Vocabulary for This Week
+
+* **Dirichlet Distribution ($\alpha$):** A continuous multivariate probability distribution. In Federated Learning research, it is the standard method for generating synthetic Non-IID data splits. The concentration parameter $\alpha$ controls the degree of skew:
+ * **Low $\alpha$ (e.g., $\alpha \to 0$):** High heterogeneity. Each client receives labels from only a few classes (extreme skew).
+ * **High $\alpha$ (e.g., $\alpha \to \infty$):** High homogeneity. The partition approaches a uniform, IID split.
+ * **$\alpha = 0.5$:** A standard benchmark for moderate label heterogeneity.
+* **Client Drift:** The optimization challenge where client models train on highly biased local objectives and update their weights in conflicting directions. When averaged, the global model results in a compromised middle ground that may fit none of the clients well.
+* **Convergence Curve:** A plot tracing model metrics (loss or accuracy) against communication rounds. Under Non-IID splits, the convergence curve will climb slower, oscillate heavily, or plateau at a lower peak compared to IID runs.
+* **Statistical Heterogeneity:** The technical term indicating that the probability distributions of data vary across clients ($P_i \neq P_j$ for client $i$ and $j$).
+
+---
+
+## 2. Mathematical Framework
+
+The federated optimization objective decomposes the global learning problem into a weighted sum of independent local objectives.
+
+### Global Empirical Risk Minimization
+The goal is to find the parameter weights $w$ that minimize the global loss function $f(w)$:
+
+$$\min_{w} f(w)$$
+
+* $w \in \mathbb{R}^d$: The neural network parameters (weights) being trained.
+* $f(w)$: The global loss function measuring prediction error.
+
+### Global Objective Function
+Since the server cannot access raw data on edge devices, the global loss is computed as a weighted average of local client losses:
+
+$$f(w) = \sum_{k=1}^{K} \frac{n_k}{n} F_k(w)$$
+
+* $K$: Total number of clients (e.g., $K = 5$).
+* $n_k$: The number of training samples on client $k$.
+* $n$: The total number of training samples across all clients ($n = \sum_k n_k$).
+* $F_k(w)$: The local objective (loss) function of client $k$.
+
+### Local Objective Function
+Each client's local objective is the average loss computed over its unique partition $P_k$:
+
+$$F_k(w) = \frac{1}{n_k} \sum_{i \in P_k} f_i(w)$$
+
+* $P_k$: The set of data indices stored on client $k$.
+* $f_i(w)$: The loss computed on a single training example $i$.
+
+### Federated Averaging (FedAvg) Update
+At each round, the server aggregates client models by taking an example-weighted average of their local parameters:
+
+$$w_{t+1} = \sum_{k=1}^{K} \frac{n_k}{n} w_{t+1}^k$$
+
+* $w_{t+1}$: The updated global model weights for the next round.
+* $w_{t+1}^k$: The local model weights returned by client $k$ after training on its local dataset.
+
+---
+
+## 3. Study Queue
+
+* **YouTube:** Search *"non-IID federated learning explained"* to watch visual animations of client drift. Focus on videos (10–15 minutes) showing why local optimizations struggle to converge globally.
+* **Paper 1 (Abstract & Intro):** *Tackling the Objective Inconsistency Problem in Heterogeneous Federated Optimization* ([arXiv:2007.07481](https://arxiv.org/abs/2007.07481)). Understand how Non-IID data creates "objective inconsistency" (each client optimizing a different local minimum).
+* **Paper 2 (Section 2):** *Advances and Open Problems in Federated Learning* ([arXiv:1912.04977](https://arxiv.org/abs/1912.04977)). Search for the "non-IID" sections to read about types of data skew (label skew, feature skew, quantity skew, and temporal skew).
+
+---
+
+## 4. Setup & Running the Simulations
+
+### Step 1: Environment Setup
+Initialize your virtual environment and install the required dependencies:
+
+```bash
+# Navigate to the week-3 directory
+cd week-3
+
+# Create a virtual environment
+python -m venv .venv
+source .venv/bin/activate # Windows: .venv\Scripts\activate
+
+# Install requirements
+pip install -r requirements.txt
+```
+
+### Step 2: Run the Experiments
+We provide two alternative orchestrators to run these experiments. Both support the exact same arguments and generate matching logs and plots:
+
+#### Option A: Pure PyTorch Runner (Recommended)
+This runs a pure PyTorch simulation of FedAvg in a single local process. It is fast, lightweight, and sandbox-compatible.
+```bash
+# Run both simulations sequentially (generates IID & Non-IID CSVs and overlays the plot)
+python main.py
+
+# Run with single-mode CLI options
+python main.py --mode iid
+python main.py --mode noniid --alpha 0.1
+```
+
+#### Option B: Ray-backed Flower Runner
+This runs the simulation using Flower's simulation engine and Ray. It spawns isolated subprocesses to avoid Ray state conflicts.
+```bash
+# Run both Flower simulations sequentially
+python main_flower.py
+
+# Run with single-mode CLI options
+python main_flower.py --mode iid
+python main_flower.py --mode noniid --alpha 0.1
+```
+
+Regardless of which option you run, they will:
+* Save logs to `iid_results.csv` and `noniid_results.csv`.
+* Generate the comparison chart `convergence_comparison.png`.
+
+To run the plotter independently:
+```bash
+python plot.py
+```
+
+### Step 3: Interactive Dashboard Walkthrough
+To run the interactive concept dashboard, open `walkthrough/index.html` in any web browser, or use a local development server:
+
+```bash
+# If using Python to host locally:
+python -m http.server 8000
+# Then visit http://localhost:8000/walkthrough/
+```
diff --git a/week-3/fed/__init__.py b/week-3/fed/__init__.py
new file mode 100644
index 0000000..6badc88
--- /dev/null
+++ b/week-3/fed/__init__.py
@@ -0,0 +1 @@
+# Expose the fed package.
diff --git a/week-3/fed/_compat.py b/week-3/fed/_compat.py
new file mode 100644
index 0000000..5983b5b
--- /dev/null
+++ b/week-3/fed/_compat.py
@@ -0,0 +1,52 @@
+"""Environment workaround — import this BEFORE importing ``flwr.simulation``.
+
+Some environments (notably anaconda installs) ship a TensorFlow whose own
+imports are broken — e.g. a TensorFlow/JAX build that expects a newer numpy and
+raises ``AttributeError: module 'numpy.dtypes' has no attribute 'StringDType'``.
+
+Flower only imports TensorFlow for an *optional* GPU-memory helper, and guards
+it with ``try: import tensorflow except ModuleNotFoundError``. But a broken TF
+raises ``AttributeError`` (not ``ModuleNotFoundError``), so the guard misses it
+and the error escapes, taking down ``import flwr.simulation``.
+
+If the installed TensorFlow is broken, we swap it for a harmless empty stub
+module so ``import tensorflow`` simply succeeds. We never call the GPU helper
+that would actually touch TF (the model is pure PyTorch, and this is a CPU
+simulation), so the stub is enough. This is a no-op when TF imports fine or is
+absent; the cleanest long-term fix is a dedicated venv without a broken TF
+(see README).
+"""
+
+import sys
+import types
+from importlib.machinery import ModuleSpec
+
+
+def apply():
+ """Replace a broken TensorFlow install with an inert stub module."""
+ try:
+ import tensorflow # noqa: F401
+ except ModuleNotFoundError as e:
+ if e.name == "tensorflow":
+ return # TF not installed at all — Flower handles this fine.
+
+ # TF is installed, but an internal package dependency is missing (broken install).
+ # We must stub it out.
+ _stub_tensorflow()
+ except Exception:
+ # TF is installed but broken in other ways (e.g. AttributeError, ImportError).
+ # We must stub it out.
+ _stub_tensorflow()
+
+
+def _stub_tensorflow():
+ """Helper to remove partially loaded tensorflow modules and replace with a stub."""
+ import sys
+ import types
+ from importlib.machinery import ModuleSpec
+
+ for name in [m for m in sys.modules if m == "tensorflow" or m.startswith("tensorflow.")]:
+ del sys.modules[name]
+ stub = types.ModuleType("tensorflow")
+ stub.__spec__ = ModuleSpec("tensorflow", loader=None)
+ sys.modules["tensorflow"] = stub
diff --git a/week-3/fed/client.py b/week-3/fed/client.py
new file mode 100644
index 0000000..8db9a41
--- /dev/null
+++ b/week-3/fed/client.py
@@ -0,0 +1,66 @@
+"""Flower client for the federated MNIST example with custom data configuration."""
+
+import os
+import torch
+import torch.nn as nn
+import flwr as fl
+from flwr.common import Context
+
+from .data import load_data
+from .model import MLP
+
+
+class FlowerClient(fl.client.NumPyClient):
+ def __init__(self, client_id, num_clients=5, data_mode="iid", alpha=0.5):
+ self.model = MLP()
+ self.trainloader, self.valloader = load_data(
+ client_id, num_clients=num_clients, data_mode=data_mode, alpha=alpha
+ )
+ self.criterion = nn.CrossEntropyLoss()
+ self.optimizer = torch.optim.SGD(self.model.parameters(), lr=0.01)
+
+ def get_parameters(self, config):
+ return [p.detach().cpu().numpy() for p in self.model.parameters()]
+
+ def set_parameters(self, parameters):
+ for p, new_p in zip(self.model.parameters(), parameters):
+ p.data = torch.tensor(new_p)
+
+ def fit(self, parameters, config):
+ self.set_parameters(parameters)
+ self.model.train()
+ for images, labels in self.trainloader:
+ self.optimizer.zero_grad()
+ loss = self.criterion(self.model(images), labels)
+ loss.backward()
+ self.optimizer.step()
+ return self.get_parameters(config), len(self.trainloader.dataset), {}
+
+ def evaluate(self, parameters, config):
+ self.set_parameters(parameters)
+ self.model.eval()
+ correct, total, loss_sum = 0, 0, 0.0
+ with torch.no_grad():
+ for images, labels in self.valloader:
+ outputs = self.model(images)
+ loss_sum += self.criterion(outputs, labels).item() * labels.size(0)
+ correct += (outputs.argmax(1) == labels).sum().item()
+ total += labels.size(0)
+
+ avg_loss = loss_sum / total if total > 0 else 0.0
+ accuracy = correct / total if total > 0 else 0.0
+ return avg_loss, total, {"accuracy": accuracy}
+
+
+def client_fn(context: Context):
+ # Flower (>=1.x) hands the client a Context.
+ partition_id = int(context.node_config["partition-id"])
+ num_partitions = int(context.node_config["num-partitions"])
+
+ # Read environment variables set in Ray runtime_env
+ data_mode = os.environ.get("DATA_MODE", "iid")
+ alpha = float(os.environ.get("ALPHA", "0.5"))
+
+ return FlowerClient(
+ partition_id, num_clients=num_partitions, data_mode=data_mode, alpha=alpha
+ ).to_client()
diff --git a/week-3/fed/data.py b/week-3/fed/data.py
new file mode 100644
index 0000000..f0bddb3
--- /dev/null
+++ b/week-3/fed/data.py
@@ -0,0 +1,98 @@
+"""Data loading and partitioning for the federated MNIST example with Dirichlet Non-IID."""
+
+import os
+import numpy as np
+import torch
+from torch.utils.data import DataLoader, Subset, random_split
+from torchvision import datasets, transforms
+
+# MNIST is pre-downloaded in week-2/data and reused here to avoid network requests.
+DATA_ROOT = "../week-2/data"
+
+
+def dirichlet_split(dataset, num_clients, alpha=0.5, seed=42):
+ """Splits a dataset across clients using a Dirichlet distribution over labels."""
+ np.random.seed(seed)
+
+ # Fast path for extraction of labels in PyTorch datasets (e.g., MNIST)
+ if hasattr(dataset, "targets"):
+ labels = dataset.targets.numpy()
+ else:
+ labels = np.array([dataset[i][1] for i in range(len(dataset))])
+
+ num_classes = len(np.unique(labels))
+ client_indices = [[] for _ in range(num_clients)]
+
+ for class_id in range(num_classes):
+ class_indices = np.where(labels == class_id)[0]
+ np.random.shuffle(class_indices)
+
+ # Draw class proportions from Dirichlet distribution
+ proportions = np.random.dirichlet([alpha] * num_clients)
+ proportions = (proportions * len(class_indices)).astype(int)
+
+ # Adjust remainder to sum exactly to the number of class indices
+ proportions[-1] = len(class_indices) - proportions[:-1].sum()
+
+ # Partition indices using cumsum
+ splits = np.split(class_indices, np.cumsum(proportions)[:-1])
+ for client_id, split in enumerate(splits):
+ client_indices[client_id].extend(split.tolist())
+
+ # Safeguard check: if a client gets 0 indices, transfer from the client with the most indices
+ for client_id in range(num_clients):
+ if len(client_indices[client_id]) == 0:
+ max_client_id = int(np.argmax([len(c) for c in client_indices]))
+ # Take a full batch (e.g. 32 samples) from the max client
+ transfer_size = min(32, len(client_indices[max_client_id]) // 2)
+ if transfer_size > 0:
+ taken = client_indices[max_client_id][-transfer_size:]
+ client_indices[max_client_id] = client_indices[max_client_id][:-transfer_size]
+ client_indices[client_id].extend(taken)
+
+ return [Subset(dataset, indices) for indices in client_indices]
+
+
+def load_data(client_id, num_clients=5, data_mode="iid", alpha=0.5, batch_size=32, seed=42):
+ """Return (train_loader, val_loader) containing this client's partitioned datasets.
+
+ Supports both 'iid' (uniform random split) and 'noniid' (Dirichlet label skew split).
+ """
+ transform = transforms.ToTensor()
+ # Load training dataset
+ train_dataset = datasets.MNIST(
+ DATA_ROOT, train=True, download=True, transform=transform
+ )
+ # Load test/validation dataset
+ test_dataset = datasets.MNIST(
+ DATA_ROOT, train=False, download=True, transform=transform
+ )
+
+ if data_mode == "iid":
+ # Partition training set
+ train_partition_size = len(train_dataset) // num_clients
+ train_lengths = [train_partition_size] * (num_clients - 1)
+ train_lengths.append(len(train_dataset) - train_partition_size * (num_clients - 1))
+ train_generator = torch.Generator().manual_seed(seed)
+ train_partitions = random_split(train_dataset, train_lengths, generator=train_generator)
+
+ # Partition testing set
+ test_partition_size = len(test_dataset) // num_clients
+ test_lengths = [test_partition_size] * (num_clients - 1)
+ test_lengths.append(len(test_dataset) - test_partition_size * (num_clients - 1))
+ test_generator = torch.Generator().manual_seed(seed)
+ test_partitions = random_split(test_dataset, test_lengths, generator=test_generator)
+
+ elif data_mode == "noniid":
+ # Partition training set via Dirichlet split
+ train_partitions = dirichlet_split(train_dataset, num_clients, alpha=alpha, seed=seed)
+ # Partition testing set via Dirichlet split using the same parameters
+ test_partitions = dirichlet_split(test_dataset, num_clients, alpha=alpha, seed=seed)
+
+ else:
+ raise ValueError(f"Unknown data_mode: {data_mode}")
+
+ train_loader = DataLoader(train_partitions[client_id], batch_size=batch_size, shuffle=True)
+ val_loader = DataLoader(test_partitions[client_id], batch_size=batch_size, shuffle=False)
+
+ return train_loader, val_loader
diff --git a/week-3/fed/model.py b/week-3/fed/model.py
new file mode 100644
index 0000000..11449c7
--- /dev/null
+++ b/week-3/fed/model.py
@@ -0,0 +1,19 @@
+"""Model definition for the federated MNIST example."""
+
+import torch.nn as nn
+
+
+class MLP(nn.Module):
+ """A simple multi-layer perceptron for MNIST (28x28 -> 10 classes)."""
+
+ def __init__(self):
+ super().__init__()
+ self.fc = nn.Sequential(
+ nn.Flatten(),
+ nn.Linear(784, 128),
+ nn.ReLU(),
+ nn.Linear(128, 10),
+ )
+
+ def forward(self, x):
+ return self.fc(x)
diff --git a/week-3/fed/strategy.py b/week-3/fed/strategy.py
new file mode 100644
index 0000000..1b20395
--- /dev/null
+++ b/week-3/fed/strategy.py
@@ -0,0 +1,48 @@
+"""Flower FedAvg strategy with custom CSV logging for training performance comparison."""
+
+import csv
+from flwr.server.strategy import FedAvg
+
+
+class LoggingFedAvg(FedAvg):
+ def __init__(self, log_path, **kwargs):
+ super().__init__(**kwargs)
+ self.log_path = log_path
+ self.results_log = []
+ # Create CSV file and write header up-front
+ with open(self.log_path, "w", newline="") as f:
+ writer = csv.writer(f)
+ writer.writerow(["round", "accuracy", "loss"])
+
+ def aggregate_evaluate(self, server_round, results, failures):
+ aggregated = super().aggregate_evaluate(server_round, results, failures)
+ if aggregated:
+ loss, metrics = aggregated
+
+ # Extract and compute weighted accuracy across all reporting clients
+ if results:
+ total_examples = sum(res.num_examples for _, res in results)
+ acc = (
+ sum(res.num_examples * res.metrics["accuracy"] for _, res in results)
+ / total_examples
+ )
+ else:
+ acc = 0.0
+
+ self.results_log.append([server_round, acc, loss])
+ print(f"Round {server_round} | Accuracy: {acc:.4f} | Loss: {loss:.4f}")
+
+ # Append results to the CSV log file immediately
+ with open(self.log_path, "a", newline="") as f:
+ writer = csv.writer(f)
+ writer.writerow([server_round, acc, loss])
+
+ # Ensure accuracy is propagated back in the returned metrics
+ aggregated_metrics = {**(metrics or {}), "accuracy": acc}
+ return loss, aggregated_metrics
+
+ return aggregated
+
+ def save(self):
+ # Already saved progressively, but here is a hook for manual saving if needed
+ pass
diff --git a/week-3/main.py b/week-3/main.py
new file mode 100644
index 0000000..7175c71
--- /dev/null
+++ b/week-3/main.py
@@ -0,0 +1,181 @@
+"""Entry point: run the federated MNIST simulations using a pure PyTorch runner.
+
+This script bypasses Ray network/socket constraints in sandboxed environments,
+while remaining mathematically identical to Flower's FedAvg.
+
+Usage:
+ python main.py # Runs both IID and Non-IID simulations and plots results
+ python main.py --mode iid # Runs only IID
+ python main.py --mode noniid --alpha 0.5 # Runs only Non-IID
+"""
+
+import argparse
+import copy
+import csv
+import os
+import sys
+import torch
+import torch.nn as nn
+
+from fed.data import load_data
+from fed.model import MLP
+
+NUM_CLIENTS = 5
+NUM_ROUNDS = 10
+
+
+def run_simulation(data_mode, log_path, alpha=0.5):
+ """Run a federated simulation round-by-round using pure PyTorch."""
+ print(f"\n=======================================================")
+ print(f"Starting {data_mode.upper()} Simulation (Rounds: {NUM_ROUNDS}, Clients: {NUM_CLIENTS})")
+ print(f"=======================================================\n")
+
+ # Initialize the global server model and loss function
+ global_model = MLP()
+ criterion = nn.CrossEntropyLoss()
+
+ # Load dataloaders for each simulated client device
+ client_train_loaders = []
+ client_val_loaders = []
+ for cid in range(NUM_CLIENTS):
+ train_loader, val_loader = load_data(
+ client_id=cid,
+ num_clients=NUM_CLIENTS,
+ data_mode=data_mode,
+ alpha=alpha,
+ seed=42,
+ )
+ client_train_loaders.append(train_loader)
+ client_val_loaders.append(val_loader)
+ print(f"Client {cid + 1} dataset loaded: {len(train_loader.dataset)} train samples, {len(val_loader.dataset)} val samples.")
+
+ # Truncate CSV log file and write header
+ with open(log_path, "w", newline="") as f:
+ writer = csv.writer(f)
+ writer.writerow(["round", "accuracy", "loss"])
+
+ # Run communication rounds
+ for server_round in range(1, NUM_ROUNDS + 1):
+ # 1. Distribute global model parameters to clients
+ global_params = copy.deepcopy(global_model.state_dict())
+
+ client_weights = []
+ client_sizes = []
+
+ # 2. Local client training phase
+ for cid in range(NUM_CLIENTS):
+ # Instantiate local client model and load global parameters
+ local_model = MLP()
+ local_model.load_state_dict(global_params)
+ local_model.train()
+
+ optimizer = torch.optim.SGD(local_model.parameters(), lr=0.01)
+ loader = client_train_loaders[cid]
+
+ # Train locally for 1 epoch (one pass over client partition)
+ for images, labels in loader:
+ optimizer.zero_grad()
+ outputs = local_model(images)
+ loss = criterion(outputs, labels)
+ loss.backward()
+ optimizer.step()
+
+ # Record model weights and dataset size for FedAvg weighting
+ client_weights.append(local_model.state_dict())
+ client_sizes.append(len(loader.dataset))
+
+ # 3. Server aggregation phase (Weighted average of client weights)
+ total_samples = sum(client_sizes)
+ aggregated_weights = copy.deepcopy(global_model.state_dict())
+
+ for key in aggregated_weights.keys():
+ weighted_sum = torch.zeros_like(aggregated_weights[key], dtype=torch.float32)
+ for cid in range(NUM_CLIENTS):
+ weight = client_sizes[cid] / total_samples
+ weighted_sum += client_weights[cid][key].float() * weight
+ aggregated_weights[key] = weighted_sum
+
+ # Update the global server model
+ global_model.load_state_dict(aggregated_weights)
+
+ # 4. Global model evaluation phase
+ global_model.eval()
+ client_losses = []
+ client_accuracies = []
+ client_val_sizes = []
+
+ with torch.no_grad():
+ for cid in range(NUM_CLIENTS):
+ loader = client_val_loaders[cid]
+ correct, total, loss_sum = 0, 0, 0.0
+ for images, labels in loader:
+ outputs = global_model(images)
+ loss_sum += criterion(outputs, labels).item() * labels.size(0)
+ correct += (outputs.argmax(1) == labels).sum().item()
+ total += labels.size(0)
+
+ client_losses.append(loss_sum / total if total > 0 else 0.0)
+ client_accuracies.append(correct / total if total > 0 else 0.0)
+ client_val_sizes.append(total)
+
+ # Example-weighted average calculation (FedAvg) over validation samples
+ total_val_samples = sum(client_val_sizes)
+ round_loss = sum(l * n for l, n in zip(client_losses, client_val_sizes)) / total_val_samples if total_val_samples > 0 else 0.0
+ round_acc = sum(a * n for a, n in zip(client_accuracies, client_val_sizes)) / total_val_samples if total_val_samples > 0 else 0.0
+
+ print(f"Round {server_round:02d} | Accuracy: {round_acc:.4f} | Loss: {round_loss:.4f}")
+
+ # Write results log row to CSV
+ with open(log_path, "a", newline="") as f:
+ writer = csv.writer(f)
+ writer.writerow([server_round, round_acc, round_loss])
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Run pure PyTorch FL simulation.")
+ parser.add_argument(
+ "--mode",
+ type=str,
+ choices=["iid", "noniid", "both"],
+ default="both",
+ help="Simulation mode: 'iid', 'noniid', or 'both' (default: both)",
+ )
+ parser.add_argument(
+ "--alpha",
+ type=float,
+ default=0.5,
+ help="Dirichlet distribution alpha parameter for Non-IID mode (default: 0.5)",
+ )
+ args = parser.parse_args()
+
+ if args.mode == "both":
+ run_simulation("iid", "iid_results.csv")
+ run_simulation("noniid", "noniid_results.csv", args.alpha)
+
+ print("\n=== GENERATING CONVERGENCE COMPARISON PLOT ===")
+ try:
+ import plot
+ plot.generate_plot("iid_results.csv", "noniid_results.csv", "convergence_comparison.png")
+ print("Successfully created 'convergence_comparison.png'.")
+ except Exception as e:
+ print(f"Could not generate plot automatically: {e}")
+
+ elif args.mode == "iid":
+ run_simulation("iid", "iid_results.csv")
+ try:
+ import plot
+ plot.generate_plot("iid_results.csv", "noniid_results.csv", "convergence_comparison.png")
+ except Exception as e:
+ print(f"Could not generate plot automatically: {e}")
+
+ elif args.mode == "noniid":
+ run_simulation("noniid", "noniid_results.csv", args.alpha)
+ try:
+ import plot
+ plot.generate_plot("iid_results.csv", "noniid_results.csv", "convergence_comparison.png")
+ except Exception as e:
+ print(f"Could not generate plot automatically: {e}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/week-3/main_flower.py b/week-3/main_flower.py
new file mode 100644
index 0000000..1a032f4
--- /dev/null
+++ b/week-3/main_flower.py
@@ -0,0 +1,125 @@
+"""Flower-based simulation orchestrator for the Week 3 fellowship.
+
+Runs both simulations using Flower's simulation engine and Ray.
+
+Usage:
+ python main_flower.py
+"""
+
+import argparse
+import os
+import subprocess
+import sys
+
+
+def run_simulation(data_mode, log_path, alpha=0.5):
+ """Run a single Flower simulation round in the current process."""
+ from fed import _compat
+ _compat.apply()
+
+ import flwr as fl
+ from fed.client import client_fn
+ from fed.strategy import LoggingFedAvg
+ import tempfile
+ import uuid
+
+ NUM_CLIENTS = 5
+ NUM_ROUNDS = 10
+
+ print(f"\n=======================================================")
+ print(f"Starting {data_mode.upper()} Flower Simulation (Rounds: {NUM_ROUNDS}, Clients: {NUM_CLIENTS})")
+ print(f"=======================================================\n")
+
+ # Set local environment variables for the main process
+ os.environ["DATA_MODE"] = data_mode
+ os.environ["ALPHA"] = str(alpha)
+
+ # Redirect all temporary files to a platform-native unique path
+ base_temp = tempfile.gettempdir()
+ local_tmp = os.path.join(base_temp, f"ray_fellowship_{uuid.uuid4().hex[:8]}")
+ os.makedirs(local_tmp, exist_ok=True)
+ os.environ["RAY_TMPDIR"] = local_tmp
+ os.environ["TMPDIR"] = local_tmp
+ os.environ["TEMP"] = local_tmp
+ os.environ["TMP"] = local_tmp
+
+ strategy = LoggingFedAvg(
+ fraction_fit=1.0,
+ fraction_evaluate=1.0,
+ min_fit_clients=NUM_CLIENTS,
+ min_evaluate_clients=NUM_CLIENTS,
+ min_available_clients=NUM_CLIENTS,
+ log_path=log_path,
+ )
+
+ fl.simulation.start_simulation(
+ client_fn=client_fn,
+ num_clients=NUM_CLIENTS,
+ config=fl.server.ServerConfig(num_rounds=NUM_ROUNDS),
+ strategy=strategy,
+ ray_init_args={
+ "_temp_dir": local_tmp,
+ "runtime_env": {
+ "env_vars": {
+ "PYTHONPATH": os.getcwd(),
+ "DATA_MODE": data_mode,
+ "ALPHA": str(alpha),
+ "RAY_TMPDIR": local_tmp,
+ "TMPDIR": local_tmp,
+ "TEMP": local_tmp,
+ "TMP": local_tmp,
+ },
+ "worker_process_setup_hook": "fed._compat.apply",
+ },
+ "include_dashboard": False,
+ "ignore_reinit_error": True,
+ },
+ )
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Run Flower simulation.")
+ parser.add_argument(
+ "--mode",
+ type=str,
+ choices=["iid", "noniid", "both"],
+ default="both",
+ help="Simulation mode: 'iid', 'noniid', or 'both'",
+ )
+ parser.add_argument(
+ "--alpha",
+ type=float,
+ default=0.5,
+ help="Dirichlet alpha (default: 0.5)",
+ )
+ args = parser.parse_args()
+
+ if args.mode in ["noniid", "both"] and args.alpha <= 0:
+ parser.error("--alpha must be greater than zero")
+
+ if args.mode == "both":
+ print("\n=== RUNNING IID FLOWER SIMULATION ===")
+ subprocess.run([sys.executable, "main_flower.py", "--mode", "iid"], check=True)
+
+ print("\n=== RUNNING NON-IID FLOWER SIMULATION ===")
+ subprocess.run(
+ [sys.executable, "main_flower.py", "--mode", "noniid", "--alpha", str(args.alpha)],
+ check=True,
+ )
+
+ print("\n=== GENERATING CONVERGENCE COMPARISON PLOT ===")
+ try:
+ import plot
+ plot.generate_plot("iid_results.csv", "noniid_results.csv", "convergence_comparison.png", args.alpha)
+ except Exception as e:
+ print(f"Could not generate plot: {e}")
+
+ elif args.mode == "iid":
+ run_simulation("iid", "iid_results.csv")
+
+ elif args.mode == "noniid":
+ run_simulation("noniid", "noniid_results.csv", args.alpha)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/week-3/plot.py b/week-3/plot.py
new file mode 100644
index 0000000..fd84961
--- /dev/null
+++ b/week-3/plot.py
@@ -0,0 +1,156 @@
+"""Script to load IID and Non-IID simulation results from CSV and generate a comparative convergence plot using Pillow.
+
+This replaces matplotlib to avoid sandbox/network issues, while producing a modern dark-themed chart.
+
+Usage:
+ python plot.py
+"""
+
+import csv
+from PIL import Image, ImageDraw, ImageFont
+
+
+def load_log(filename):
+ """Load rounds, accuracy, and loss from a CSV log file."""
+ rounds, accs, losses = [], [], []
+ with open(filename, "r") as f:
+ reader = csv.DictReader(f)
+ for row in reader:
+ rounds.append(int(row["round"]))
+ accs.append(float(row["accuracy"]))
+ losses.append(float(row["loss"]))
+ return rounds, accs, losses
+
+
+def generate_plot(iid_file="iid_results.csv", noniid_file="noniid_results.csv", output_image="convergence_comparison.png", noniid_alpha=0.5):
+ """Load logged CSV results and save an overlaid comparison plot using Pillow."""
+ iid_rounds, iid_accs = [], []
+ noniid_rounds, noniid_accs = [], []
+
+ try:
+ iid_rounds, iid_accs, _ = load_log(iid_file)
+ except FileNotFoundError:
+ print(f"Warning: {iid_file} not found. Skipping IID curve.")
+
+ try:
+ noniid_rounds, noniid_accs, _ = load_log(noniid_file)
+ except FileNotFoundError:
+ print(f"Warning: {noniid_file} not found. Skipping Non-IID curve.")
+
+ if not iid_rounds and not noniid_rounds:
+ print("Error: No result logs found to plot. Run simulations first.")
+ return
+
+ # Image dimensions
+ w, h = 900, 550
+ # Create dark-themed background matching fellowship dashboard aesthetics (#090d16)
+ img = Image.new("RGB", (w, h), color=(9, 13, 22))
+ draw = ImageDraw.Draw(img)
+
+ try:
+ font = ImageFont.load_default()
+ except Exception:
+ font = None
+
+ # Chart padding
+ pad_left = 70
+ pad_right = 40
+ pad_top = 70
+ pad_bottom = 60
+ chart_w = w - pad_left - pad_right
+ chart_h = h - pad_top - pad_bottom
+
+ # Color Palette
+ color_grid = (26, 36, 57) # Grid lines (#1a2439)
+ color_text = (156, 163, 175) # Text labels (#9ca3af)
+ color_iid = (59, 130, 246) # IID line - Sleek blue (#3b82f6)
+ color_noniid = (239, 68, 68) # Non-IID line - Sleek red (#ef4444)
+ color_border = (55, 65, 81) # Border borders (#374151)
+
+ # 1. Draw Y-axis grid lines and ticks (0.0 to 1.0)
+ for i in range(6):
+ val = i / 5.0
+ y = pad_top + int(chart_h * (1.0 - val))
+
+ # Grid line
+ draw.line([(pad_left, y), (w - pad_right, y)], fill=color_grid, width=1)
+
+ # Label
+ label_text = f"{val:.1f}"
+ draw.text((pad_left - 35, y - 6), label_text, fill=color_text, font=font)
+
+ # 2. Draw X-axis grid lines and ticks
+ max_round = max((iid_rounds or [10]) + (noniid_rounds or [10]))
+ round_span = max(max_round - 1, 1)
+ for r in range(1, max_round + 1):
+ x = pad_left + int(chart_w * (r - 1) / round_span)
+
+ # Grid line
+ draw.line([(x, pad_top), (x, h - pad_bottom)], fill=color_grid, width=1)
+
+ # Label
+ draw.text((x - 4, h - pad_bottom + 12), str(r), fill=color_text, font=font)
+
+ # 3. Draw chart border axes
+ draw.line([(pad_left, pad_top), (pad_left, h - pad_bottom), (w - pad_right, h - pad_bottom)], fill=color_border, width=2)
+
+ # Coordinate mapping helper
+ def map_coords(round_num, acc):
+ x = pad_left + int(chart_w * (round_num - 1) / round_span)
+ y = pad_top + int(chart_h * (1.0 - acc))
+ return (x, y)
+
+ # 4. Plot IID line & points (solid blue line)
+ if iid_rounds:
+ pts = [map_coords(r, acc) for r, acc in zip(iid_rounds, iid_accs)]
+ for i in range(len(pts) - 1):
+ draw.line([pts[i], pts[i+1]], fill=color_iid, width=3)
+ for pt in pts:
+ # Circle point
+ draw.ellipse([pt[0]-4, pt[1]-4, pt[0]+4, pt[1]+4], fill=(255, 255, 255), outline=color_iid, width=2)
+
+ # 5. Plot Non-IID line & points (dashed red line)
+ if noniid_rounds:
+ pts = [map_coords(r, acc) for r, acc in zip(noniid_rounds, noniid_accs)]
+ for i in range(len(pts) - 1):
+ p1, p2 = pts[i], pts[i+1]
+ # Simple dash generator
+ steps = 10
+ for s in range(steps):
+ if s % 2 == 0:
+ x1 = p1[0] + (p2[0] - p1[0]) * s / steps
+ y1 = p1[1] + (p2[1] - p1[1]) * s / steps
+ x2 = p1[0] + (p2[0] - p1[0]) * (s + 1) / steps
+ y2 = p1[1] + (p2[1] - p1[1]) * (s + 1) / steps
+ draw.line([(x1, y1), (x2, y2)], fill=color_noniid, width=3)
+ for pt in pts:
+ # Square point
+ draw.rectangle([pt[0]-4, pt[1]-4, pt[0]+4, pt[1]+4], fill=(255, 255, 255), outline=color_noniid, width=2)
+
+ # 6. Title and Axis Labels
+ draw.text((pad_left, 25), "FedAvg Convergence: IID vs. Non-IID MNIST", fill=(255, 255, 255), font=font)
+ draw.text((pad_left + chart_w // 2 - 50, h - 25), "Communication Round", fill=color_text, font=font)
+ draw.text((pad_left, pad_top - 20), "Weighted Accuracy", fill=color_text, font=font)
+
+ # 7. Legend panel
+ leg_x = w - pad_right - 250
+ leg_y = pad_top + 15
+ draw.rectangle([leg_x, leg_y, w - pad_right - 10, leg_y + 60], fill=(17, 25, 40), outline=color_border)
+
+ # Legend - IID
+ draw.line([(leg_x + 15, leg_y + 18), (leg_x + 45, leg_y + 18)], fill=color_iid, width=3)
+ draw.ellipse([leg_x + 27, leg_y + 15, leg_x + 33, leg_y + 21], fill=(255, 255, 255), outline=color_iid, width=2)
+ draw.text((leg_x + 55, leg_y + 12), "IID (Uniform Split)", fill=color_text, font=font)
+
+ # Legend - Non-IID
+ draw.line([(leg_x + 15, leg_y + 38), (leg_x + 27, leg_y + 38)], fill=color_noniid, width=3)
+ draw.line([(leg_x + 33, leg_y + 38), (leg_x + 45, leg_y + 38)], fill=color_noniid, width=3)
+ draw.rectangle([leg_x + 27, leg_y + 35, leg_x + 33, leg_y + 41], fill=(255, 255, 255), outline=color_noniid, width=2)
+ draw.text((leg_x + 55, leg_y + 32), f"Non-IID (alpha={noniid_alpha})", fill=color_text, font=font)
+
+ img.save(output_image)
+ print(f"Saved plot comparison to: {output_image}")
+
+
+if __name__ == "__main__":
+ generate_plot()
diff --git a/week-3/requirements.txt b/week-3/requirements.txt
new file mode 100644
index 0000000..c775292
--- /dev/null
+++ b/week-3/requirements.txt
@@ -0,0 +1,5 @@
+flwr[simulation]>=1.7,<2.0
+torch>=2.0
+torchvision>=0.15
+Pillow>=9.0
+numpy<2.0
diff --git a/week-3/walkthrough/app.js b/week-3/walkthrough/app.js
new file mode 100644
index 0000000..9b141e3
--- /dev/null
+++ b/week-3/walkthrough/app.js
@@ -0,0 +1,871 @@
+// --- Tab Navigation ---
+function switchTab(tabId) {
+ document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
+ document.querySelectorAll('.nav-btn').forEach(el => el.classList.remove('active'));
+
+ document.getElementById(`tab-${tabId}`).classList.add('active');
+ document.getElementById(`tab-btn-${tabId}`).classList.add('active');
+
+ // Trigger canvas resizing or initialization when tab changes
+ if (tabId === 'drift') {
+ initDriftCanvas();
+ } else if (tabId === 'results') {
+ initResultsChart();
+ }
+}
+
+// --- Copy Terminal Commands ---
+function copyText(button) {
+ const pre = button.previousElementSibling;
+ navigator.clipboard.writeText(pre.innerText).then(() => {
+ const originalText = button.innerText;
+ button.innerText = 'Copied!';
+ button.style.background = '#10b981';
+ button.style.borderColor = '#10b981';
+ button.style.color = '#fff';
+ setTimeout(() => {
+ button.innerText = originalText;
+ button.style.background = '';
+ button.style.borderColor = '';
+ button.style.color = '';
+ }, 1500);
+ });
+}
+
+// --- 1. Dirichlet Split Visualizer ---
+const DIGIT_COLORS = [
+ '#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6',
+ '#ec4899', '#06b6d4', '#84cc16', '#a855f7', '#f43f5e'
+];
+
+function initDirichletPlayground() {
+ // Generate class legend chips
+ const legendContainer = document.getElementById('digit-legend');
+ legendContainer.innerHTML = '';
+ for (let i = 0; i < 10; i++) {
+ const chip = document.createElement('div');
+ chip.className = 'legend-item-chip';
+ chip.innerHTML = `
+
+ ${i}
+ `;
+ legendContainer.appendChild(chip);
+ }
+ updateDirichletSplit();
+}
+
+// Gamma distribution sampler (Marsaglia and Tsang method)
+function sampleGamma(shape, scale = 1.0) {
+ if (shape < 1) {
+ let u = Math.random();
+ return sampleGamma(shape + 1, scale) * Math.pow(u, 1 / shape);
+ }
+ let d = shape - 1/3;
+ let c = 1 / Math.sqrt(9 * d);
+ while (true) {
+ let x = 0, v = 0;
+ do {
+ let u = Math.random();
+ x = Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * Math.random()); // Box-Muller normal
+ v = 1 + c * x;
+ } while (v <= 0);
+ v = v * v * v;
+ let u = Math.random();
+ let x2 = x * x;
+ if (u < 1 - 0.0331 * x2 * x2) {
+ return d * v * scale;
+ }
+ if (Math.log(u) < 0.5 * x2 + d * (1 - v + Math.log(v))) {
+ return d * v * scale;
+ }
+ }
+}
+
+// Dirichlet distribution sampler
+function sampleDirichlet(alpha, size) {
+ let samples = [];
+ let sum = 0;
+ for (let i = 0; i < size; i++) {
+ let sample = sampleGamma(alpha, 1.0);
+ if (sample < 1e-5) sample = 1e-5; // Prevent underflow
+ samples.push(sample);
+ sum += sample;
+ }
+ return samples.map(s => s / sum);
+}
+
+function updateDirichletSplit() {
+ const alpha = parseFloat(document.getElementById('alpha-slider').value);
+ document.getElementById('alpha-val').innerText = alpha.toFixed(2);
+
+ // Update description text dynamically
+ const desc = document.getElementById('alpha-description');
+ if (alpha <= 0.15) {
+ desc.innerHTML = `Extreme Skew: Clients get data from very few classes. A local client model training on this will have highly biased gradients, causing extreme client drift.`;
+ desc.style.color = '#ef4444';
+ } else if (alpha <= 0.6) {
+ desc.innerHTML = `Moderate Skew (α=0.5): Standard fellowship setup. Clients share partial overlap, but some digits are heavily concentrated on single devices. Client drift is moderate.`;
+ desc.style.color = '#f59e0b';
+ } else {
+ desc.innerHTML = `Near-IID Distribution: Class proportions are balanced and stable across clients. Client models optimize in similar directions; convergence is fast and smooth.`;
+ desc.style.color = '#10b981';
+ }
+
+ // Set preset button active state
+ document.querySelectorAll('.preset-btn').forEach(btn => {
+ btn.classList.toggle('active', parseFloat(btn.innerText) === alpha);
+ });
+
+ const numClients = 5;
+ const numClasses = 10;
+ const totalSamplesPerClass = 1000; // arbitrary base for visualization
+
+ // Generate Dirichlet proportions matrix: Shape (numClasses, numClients)
+ let proportions = [];
+ // Seed pseudo-random generator with a fixed sequence when alpha doesn't change
+ // to keep layout transition clean, but since we are using Math.random(), let's generate live.
+ for (let c = 0; c < numClasses; c++) {
+ proportions.push(sampleDirichlet(alpha, numClients));
+ }
+
+ // Convert proportions to client-specific profiles: Shape (numClients, numClasses)
+ let clientProfiles = [];
+ for (let client = 0; client < numClients; client++) {
+ let profile = [];
+ let totalVal = 0;
+ for (let c = 0; c < numClasses; c++) {
+ let classVal = proportions[c][client] * totalSamplesPerClass;
+ profile.push(classVal);
+ totalVal += classVal;
+ }
+ // Normalize profile so it represents exact percentages
+ clientProfiles.push(profile.map(v => (v / totalVal) * 100));
+ }
+
+ // Render client distribution stacked bar charts
+ const grid = document.getElementById('clients-distribution-grid');
+ grid.innerHTML = '';
+
+ for (let client = 0; client < numClients; client++) {
+ const clientRow = document.createElement('div');
+ clientRow.className = 'client-row';
+
+ const label = document.createElement('div');
+ label.className = 'client-label';
+ label.innerText = `Client ${client + 1}`;
+
+ const barContainer = document.createElement('div');
+ barContainer.className = 'client-bars';
+ barContainer.setAttribute('role', 'img');
+
+ // Build accessible text alternative for screen readers
+ let labelParts = [];
+ for (let c = 0; c < numClasses; c++) {
+ labelParts.push(`Class ${c}: ${clientProfiles[client][c].toFixed(1)}%`);
+ }
+ barContainer.setAttribute('aria-label', `Client ${client + 1} distribution: ` + labelParts.join(', '));
+
+ // Populate segments
+ for (let c = 0; c < numClasses; c++) {
+ const pct = clientProfiles[client][c];
+ if (pct > 0.5) { // Hide extremely tiny slivers for design cleanliness
+ const segment = document.createElement('div');
+ segment.className = 'bar-segment';
+ segment.style.width = `${pct}%`;
+ segment.style.backgroundColor = DIGIT_COLORS[c];
+ segment.setAttribute('data-percent', `Digit ${c}: ${pct.toFixed(1)}%`);
+ barContainer.appendChild(segment);
+ }
+ }
+
+ clientRow.appendChild(label);
+ clientRow.appendChild(barContainer);
+ grid.appendChild(clientRow);
+ }
+}
+
+function setAlphaPreset(val) {
+ document.getElementById('alpha-slider').value = val;
+ document.querySelectorAll('.preset-btn').forEach(btn => {
+ btn.classList.remove('active');
+ if (parseFloat(btn.innerText) === val) {
+ btn.classList.add('active');
+ }
+ });
+ updateDirichletSplit();
+}
+
+
+// --- 2. Client Drift Sandbox Simulator ---
+let driftCtx = null;
+let driftCanvas = null;
+let driftSimInterval = null;
+let driftState = {
+ globalW: { x: 100, y: 300 }, // Start point of optimization
+ clientOptima: [
+ { x: 300, y: 120, color: '#3b82f6' }, // Client 1 Local optimum (Blue)
+ { x: 480, y: 280, color: '#ef4444' }, // Client 2 Local optimum (Red)
+ { x: 380, y: 200, color: '#10b981' } // Client 3 Local optimum (Green)
+ ],
+ globalOptimum: { x: 386.6, y: 200 }, // Global average optimum
+ trajectories: [], // stores steps of clients
+ currentStep: 0,
+ maxSteps: 4, // Local epochs E
+ isSimulating: false,
+ skewMode: 'skewed'
+};
+
+function initDriftCanvas() {
+ driftCanvas = document.getElementById('drift-canvas');
+ if (!driftCanvas) return;
+ driftCtx = driftCanvas.getContext('2d');
+ resetDriftSim();
+ drawDriftFrame();
+}
+
+function resetDriftSim() {
+ if (driftSimInterval) clearInterval(driftSimInterval);
+ driftState.isSimulating = false;
+ driftState.currentStep = 0;
+
+ // Starting global weights
+ driftState.globalW = { x: 90, y: 310 };
+
+ // Set optimum points based on mode
+ if (driftState.skewMode === 'iid') {
+ // In IID mode, all client local loss minimums align with the global minimum
+ driftState.clientOptima = [
+ { x: 400, y: 180, color: '#3b82f6' },
+ { x: 400, y: 180, color: '#ef4444' },
+ { x: 400, y: 180, color: '#10b981' }
+ ];
+ driftState.globalOptimum = { x: 400, y: 180 };
+ } else {
+ // In Non-IID mode, client loss minimums are pulled in different directions
+ driftState.clientOptima = [
+ { x: 300, y: 110, color: '#3b82f6' }, // Client 1
+ { x: 500, y: 300, color: '#ef4444' }, // Client 2
+ { x: 400, y: 160, color: '#10b981' } // Client 3
+ ];
+ // Weighted center of local minima
+ driftState.globalOptimum = { x: 400, y: 190 };
+ }
+
+ // Initialize trajectories holding starting points
+ driftState.trajectories = [
+ [{ x: driftState.globalW.x, y: driftState.globalW.y }], // C1
+ [{ x: driftState.globalW.x, y: driftState.globalW.y }], // C2
+ [{ x: driftState.globalW.x, y: driftState.globalW.y }] // C3
+ ];
+
+ document.getElementById('drift-play-text').innerText = 'Simulate Round';
+ document.getElementById('drift-distance-metric').innerText = 'Client Drift: 0.00';
+ drawDriftFrame();
+}
+
+function setDriftSkew(mode) {
+ driftState.skewMode = mode;
+ document.getElementById('btn-drift-iid').classList.toggle('active', mode === 'iid');
+ document.getElementById('btn-drift-skewed').classList.toggle('active', mode === 'skewed');
+ resetDriftSim();
+}
+
+function updateDriftParams() {
+ const val = parseInt(document.getElementById('drift-epochs').value);
+ document.getElementById('drift-epochs-val').innerText = val;
+ driftState.maxSteps = val;
+ resetDriftSim();
+}
+
+function drawDriftFrame() {
+ if (!driftCtx) return;
+ driftCtx.clearRect(0, 0, driftCanvas.width, driftCanvas.height);
+
+ // 1. Draw contour maps for client loss landscapes
+ drawLossContours();
+
+ // 2. Draw global and local optimum points
+ // Draw Client Minima
+ driftState.clientOptima.forEach((opt, idx) => {
+ if (driftState.skewMode === 'skewed') {
+ driftCtx.beginPath();
+ driftCtx.arc(opt.x, opt.y, 6, 0, 2*Math.PI);
+ driftCtx.fillStyle = opt.color;
+ driftCtx.fill();
+ driftCtx.strokeStyle = '#fff';
+ driftCtx.lineWidth = 1.5;
+ driftCtx.stroke();
+
+ driftCtx.fillStyle = '#fff';
+ driftCtx.font = 'bold 9px Outfit';
+ driftCtx.fillText(`C${idx+1} Min`, opt.x - 15, opt.y - 12);
+ }
+ });
+
+ // Draw Global Minimum
+ driftCtx.beginPath();
+ driftCtx.arc(driftState.globalOptimum.x, driftState.globalOptimum.y, 8, 0, 2*Math.PI);
+ driftCtx.fillStyle = '#10b981';
+ driftCtx.fill();
+ driftCtx.strokeStyle = '#fff';
+ driftCtx.lineWidth = 2;
+ driftCtx.stroke();
+
+ driftCtx.fillStyle = '#fff';
+ driftCtx.font = 'bold 11px Outfit';
+ driftCtx.fillText("Global Optimum", driftState.globalOptimum.x - 40, driftState.globalOptimum.y - 15);
+
+ // 3. Draw client trajectories (dotted lines)
+ driftState.trajectories.forEach((traj, cIdx) => {
+ driftCtx.beginPath();
+ driftCtx.moveTo(traj[0].x, traj[0].y);
+ for (let i = 1; i < traj.length; i++) {
+ driftCtx.lineTo(traj[i].x, traj[i].y);
+ }
+ driftCtx.strokeStyle = driftState.clientOptima[cIdx].color;
+ driftCtx.lineWidth = 2;
+ driftCtx.setLineDash([4, 4]);
+ driftCtx.stroke();
+ driftCtx.setLineDash([]);
+
+ // Draw last node position of client
+ const lastPt = traj[traj.length - 1];
+ driftCtx.beginPath();
+ driftCtx.arc(lastPt.x, lastPt.y, 4, 0, 2*Math.PI);
+ driftCtx.fillStyle = driftState.clientOptima[cIdx].color;
+ driftCtx.fill();
+ });
+
+ // 4. Draw global model trajectory (solid white line)
+ // We will draw it from the start to the aggregated point
+ if (driftState.currentStep === driftState.maxSteps) {
+ // Draw aggregated global step
+ const c1Last = driftState.trajectories[0][driftState.trajectories[0].length - 1];
+ const c2Last = driftState.trajectories[1][driftState.trajectories[1].length - 1];
+ const c3Last = driftState.trajectories[2][driftState.trajectories[2].length - 1];
+
+ const avgX = (c1Last.x + c2Last.x + c3Last.x) / 3;
+ const avgY = (c1Last.y + c2Last.y + c3Last.y) / 3;
+
+ // Draw lines from client models to the aggregated global model
+ driftCtx.beginPath();
+ driftState.clientOptima.forEach((opt, idx) => {
+ const last = driftState.trajectories[idx][driftState.trajectories[idx].length - 1];
+ driftCtx.moveTo(last.x, last.y);
+ driftCtx.lineTo(avgX, avgY);
+ });
+ driftCtx.strokeStyle = 'rgba(255, 255, 255, 0.25)';
+ driftCtx.lineWidth = 1.5;
+ driftCtx.stroke();
+
+ // Draw solid global trajectory line
+ driftCtx.beginPath();
+ driftCtx.moveTo(driftState.globalW.x, driftState.globalW.y);
+ driftCtx.lineTo(avgX, avgY);
+ driftCtx.strokeStyle = '#fff';
+ driftCtx.lineWidth = 3;
+ driftCtx.stroke();
+
+ // Draw Global Model weights point
+ driftCtx.beginPath();
+ driftCtx.arc(avgX, avgY, 6, 0, 2*Math.PI);
+ driftCtx.fillStyle = '#fff';
+ driftCtx.fill();
+ driftCtx.strokeStyle = '#000';
+ driftCtx.lineWidth = 1.5;
+ driftCtx.stroke();
+
+ driftCtx.fillStyle = '#fff';
+ driftCtx.font = 'bold 11px Outfit';
+ driftCtx.fillText("w(t+1)", avgX + 10, avgY + 4);
+ }
+
+ // Draw initial global weight w(t)
+ driftCtx.beginPath();
+ driftCtx.arc(driftState.globalW.x, driftState.globalW.y, 6, 0, 2*Math.PI);
+ driftCtx.fillStyle = '#f59e0b';
+ driftCtx.fill();
+ driftCtx.strokeStyle = '#fff';
+ driftCtx.lineWidth = 1.5;
+ driftCtx.stroke();
+ driftCtx.fillStyle = '#fff';
+ driftCtx.font = 'bold 11px Outfit';
+ driftCtx.fillText("w(t)", driftState.globalW.x - 28, driftState.globalW.y + 4);
+}
+
+function drawLossContours() {
+ // Generate simple concentric circles/ellipses around optima
+ const numContours = 4;
+
+ if (driftState.skewMode === 'iid') {
+ // Only draw global contours
+ for (let r = 1; r <= numContours; r++) {
+ driftCtx.beginPath();
+ driftCtx.arc(driftState.globalOptimum.x, driftState.globalOptimum.y, r * 45, 0, 2*Math.PI);
+ driftCtx.strokeStyle = 'rgba(16, 185, 129, 0.08)';
+ driftCtx.lineWidth = 2;
+ driftCtx.stroke();
+ }
+ } else {
+ // Draw local client contours
+ driftState.clientOptima.forEach(opt => {
+ for (let r = 1; r <= numContours; r++) {
+ driftCtx.beginPath();
+ driftCtx.ellipse(opt.x, opt.y, r * 28, r * 20, Math.PI / 6, 0, 2*Math.PI);
+ driftCtx.strokeStyle = opt.color + '0d'; // hex with alpha
+ driftCtx.lineWidth = 1.5;
+ driftCtx.stroke();
+ }
+ });
+ }
+}
+
+function toggleDriftSim() {
+ if (driftState.isSimulating) {
+ resetDriftSim();
+ return;
+ }
+
+ driftState.isSimulating = true;
+ document.getElementById('drift-play-text').innerText = 'Stop Simulation';
+
+ driftSimInterval = setInterval(() => {
+ if (driftState.currentStep < driftState.maxSteps) {
+ // Local training updates
+ driftState.currentStep++;
+
+ // Advance each client toward its local minimum
+ for (let c = 0; c < 3; c++) {
+ const opt = driftState.clientOptima[c];
+ const traj = driftState.trajectories[c];
+ const currentPos = traj[traj.length - 1];
+
+ // Gradient step: move 20% closer to the target minimum
+ const lr = 0.22;
+ const nextX = currentPos.x + (opt.x - currentPos.x) * lr;
+ const nextY = currentPos.y + (opt.y - currentPos.y) * lr;
+ traj.push({ x: nextX, y: nextY });
+ }
+
+ // Calculate drift metric: standard deviation of client updates
+ const c1Last = driftState.trajectories[0][driftState.trajectories[0].length - 1];
+ const c2Last = driftState.trajectories[1][driftState.trajectories[1].length - 1];
+ const c3Last = driftState.trajectories[2][driftState.trajectories[2].length - 1];
+ const avgX = (c1Last.x + c2Last.x + c3Last.x) / 3;
+ const avgY = (c1Last.y + c2Last.y + c3Last.y) / 3;
+
+ const dist1 = Math.sqrt((c1Last.x - avgX)**2 + (c1Last.y - avgY)**2);
+ const dist2 = Math.sqrt((c2Last.x - avgX)**2 + (c2Last.y - avgY)**2);
+ const dist3 = Math.sqrt((c3Last.x - avgX)**2 + (c3Last.y - avgY)**2);
+ const avgDrift = (dist1 + dist2 + dist3) / 3;
+
+ document.getElementById('drift-distance-metric').innerText = `Client Drift: ${avgDrift.toFixed(2)} px`;
+ drawDriftFrame();
+ } else {
+ // Simulation finished
+ clearInterval(driftSimInterval);
+ driftSimInterval = null;
+ driftState.isSimulating = false;
+ document.getElementById('drift-play-text').innerText = 'Simulate Round';
+ }
+ }, 300);
+}
+
+
+// --- 3. Live CSV Results Plotter ---
+let chartCanvas = null;
+let chartCtx = null;
+let iidPlotData = [];
+let nonIidPlotData = [];
+
+// Pre-loaded logs in case user hasn't run simulation yet
+const defaultIIDData = [
+ { round: 1, accuracy: 0.782, loss: 1.250 },
+ { round: 2, accuracy: 0.845, loss: 0.713 },
+ { round: 3, accuracy: 0.869, loss: 0.545 },
+ { round: 4, accuracy: 0.881, loss: 0.467 },
+ { round: 5, accuracy: 0.888, loss: 0.423 },
+ { round: 6, accuracy: 0.896, loss: 0.395 },
+ { round: 7, accuracy: 0.901, loss: 0.372 },
+ { round: 8, accuracy: 0.905, loss: 0.358 },
+ { round: 9, accuracy: 0.908, loss: 0.344 },
+ { round: 10, accuracy: 0.912, loss: 0.331 }
+];
+
+const defaultNonIIDData = [
+ { round: 1, accuracy: 0.354, loss: 2.102 },
+ { round: 2, accuracy: 0.461, loss: 1.834 },
+ { round: 3, accuracy: 0.528, loss: 1.621 },
+ { round: 4, accuracy: 0.591, loss: 1.432 },
+ { round: 5, accuracy: 0.638, loss: 1.298 },
+ { round: 6, accuracy: 0.652, loss: 1.221 },
+ { round: 7, accuracy: 0.641, loss: 1.254 }, // oscillation
+ { round: 8, accuracy: 0.678, loss: 1.152 },
+ { round: 9, accuracy: 0.694, loss: 1.094 },
+ { round: 10, accuracy: 0.702, loss: 1.042 }
+];
+
+function initResultsChart() {
+ chartCanvas = document.getElementById('results-chart-canvas');
+ if (!chartCanvas) return;
+ chartCtx = chartCanvas.getContext('2d');
+
+ if (iidPlotData.length === 0 && nonIidPlotData.length === 0) {
+ loadDefaultPlotData();
+ } else {
+ drawChart();
+ }
+}
+
+function loadDefaultPlotData() {
+ iidPlotData = [...defaultIIDData];
+ nonIidPlotData = [...defaultNonIIDData];
+ document.getElementById('file-name-iid').innerText = "Default IID Log loaded";
+ document.getElementById('file-name-noniid').innerText = "Default Non-IID Log loaded";
+
+ updateDiscrepancyMetric();
+ drawChart();
+}
+
+function parseCSV(text) {
+ const lines = text.trim().split('\n');
+ if (lines.length < 2) return [];
+
+ const headers = lines[0].toLowerCase().split(',');
+ const roundIdx = headers.indexOf('round');
+ const accIdx = headers.indexOf('accuracy');
+ const lossIdx = headers.indexOf('loss');
+
+ if (roundIdx === -1 || accIdx === -1) {
+ alert("CSV requires 'round' and 'accuracy' columns.");
+ return [];
+ }
+
+ let parsed = [];
+ for (let i = 1; i < lines.length; i++) {
+ const parts = lines[i].split(',');
+ if (parts.length >= headers.length) {
+ parsed.push({
+ round: parseInt(parts[roundIdx]),
+ accuracy: parseFloat(parts[accIdx]),
+ loss: lossIdx !== -1 ? parseFloat(parts[lossIdx]) : 0
+ });
+ }
+ }
+ return parsed;
+}
+
+function handleCSVUpload(input, mode) {
+ const file = input.files[0];
+ if (!file) return;
+
+ document.getElementById(`file-name-${mode}`).innerText = file.name;
+
+ const reader = new FileReader();
+ reader.onload = function(e) {
+ const text = e.target.result;
+ const parsed = parseCSV(text);
+ if (parsed.length > 0) {
+ if (mode === 'iid') {
+ iidPlotData = parsed;
+ } else {
+ nonIidPlotData = parsed;
+ }
+ updateDiscrepancyMetric();
+ drawChart();
+ }
+ };
+ reader.readAsText(file);
+}
+
+function updateDiscrepancyMetric() {
+ const el = document.getElementById('discrepancy-metric');
+ if (iidPlotData.length > 0 && nonIidPlotData.length > 0) {
+ const iidRounds = iidPlotData.map(d => d.round);
+ const nonIidRounds = nonIidPlotData.map(d => d.round);
+ const commonRounds = iidRounds.filter(r => nonIidRounds.includes(r));
+
+ if (commonRounds.length > 0) {
+ const targetRound = Math.max(...commonRounds);
+ const iidRow = iidPlotData.find(d => d.round === targetRound);
+ const nonIidRow = nonIidPlotData.find(d => d.round === targetRound);
+
+ const iidAcc = iidRow.accuracy * 100;
+ const nonIidAcc = nonIidRow.accuracy * 100;
+ const diff = iidAcc - nonIidAcc;
+
+ el.innerHTML = `
+ Round ${targetRound} IID: ${iidAcc.toFixed(1)}%
+ Round ${targetRound} Non-IID: ${nonIidAcc.toFixed(1)}%
+
+ Heterogeneity Penalty: -${diff.toFixed(1)}% Accuracy
+
+ `;
+ } else {
+ el.innerText = "No matching rounds found between IID and Non-IID datasets.";
+ }
+ } else {
+ el.innerText = "Upload both CSV logs to compute performance penalty.";
+ }
+}
+
+function drawChart() {
+ if (!chartCtx) return;
+ const w = chartCanvas.width;
+ const h = chartCanvas.height;
+ chartCtx.clearRect(0, 0, w, h);
+
+ // Padding settings
+ const paddingLeft = 50;
+ const paddingRight = 20;
+ const paddingTop = 30;
+ const paddingBottom = 40;
+
+ const chartW = w - paddingLeft - paddingRight;
+ const chartH = h - paddingTop - paddingBottom;
+
+ // Draw grid background lines
+ chartCtx.strokeStyle = 'rgba(255, 255, 255, 0.05)';
+ chartCtx.lineWidth = 1;
+ for (let i = 0; i <= 5; i++) {
+ const y = paddingTop + (chartH * i / 5);
+ chartCtx.beginPath();
+ chartCtx.moveTo(paddingLeft, y);
+ chartCtx.lineTo(w - paddingRight, y);
+ chartCtx.stroke();
+
+ // Acc labels
+ chartCtx.fillStyle = '#9ca3af';
+ chartCtx.font = '10px JetBrains Mono';
+ const labelVal = (1.0 - (i / 5)).toFixed(1);
+ chartCtx.fillText(labelVal, paddingLeft - 30, y + 4);
+ }
+
+ // Determine max rounds
+ const maxR = Math.max(
+ iidPlotData.length > 0 ? iidPlotData[iidPlotData.length - 1].round : 10,
+ nonIidPlotData.length > 0 ? nonIidPlotData[nonIidPlotData.length - 1].round : 10
+ );
+ const roundSpan = Math.max(maxR - 1, 1);
+
+ // Draw round labels
+ for (let r = 1; r <= maxR; r++) {
+ const x = paddingLeft + (chartW * (r - 1) / roundSpan);
+ chartCtx.beginPath();
+ chartCtx.moveTo(x, paddingTop);
+ chartCtx.lineTo(x, h - paddingBottom);
+ chartCtx.stroke();
+
+ chartCtx.fillStyle = '#9ca3af';
+ chartCtx.font = '10px JetBrains Mono';
+ chartCtx.fillText(r, x - 4, h - paddingBottom + 18);
+ }
+
+ // Draw axes
+ chartCtx.strokeStyle = 'rgba(255, 255, 255, 0.15)';
+ chartCtx.lineWidth = 1.5;
+ chartCtx.beginPath();
+ chartCtx.moveTo(paddingLeft, paddingTop);
+ chartCtx.lineTo(paddingLeft, h - paddingBottom);
+ chartCtx.lineTo(w - paddingRight, h - paddingBottom);
+ chartCtx.stroke();
+
+ // Helper to map (round, accuracy) to coordinates
+ const mapCoords = (round, acc) => {
+ const x = paddingLeft + (chartW * (round - 1) / roundSpan);
+ const y = paddingTop + (chartH * (1.0 - acc));
+ return { x, y };
+ };
+
+ // Plot IID
+ if (iidPlotData.length > 0) {
+ chartCtx.beginPath();
+ let pts = iidPlotData.map(d => mapCoords(d.round, d.accuracy));
+ chartCtx.moveTo(pts[0].x, pts[0].y);
+ for (let i = 1; i < pts.length; i++) {
+ chartCtx.lineTo(pts[i].x, pts[i].y);
+ }
+ chartCtx.strokeStyle = '#3b82f6';
+ chartCtx.lineWidth = 2.5;
+ chartCtx.stroke();
+
+ // Draw points
+ pts.forEach(pt => {
+ chartCtx.beginPath();
+ chartCtx.arc(pt.x, pt.y, 4, 0, 2*Math.PI);
+ chartCtx.fillStyle = '#3b82f6';
+ chartCtx.fill();
+ chartCtx.strokeStyle = '#fff';
+ chartCtx.lineWidth = 1;
+ chartCtx.stroke();
+ });
+ }
+
+ // Plot Non-IID
+ if (nonIidPlotData.length > 0) {
+ chartCtx.beginPath();
+ let pts = nonIidPlotData.map(d => mapCoords(d.round, d.accuracy));
+ chartCtx.moveTo(pts[0].x, pts[0].y);
+ for (let i = 1; i < pts.length; i++) {
+ chartCtx.lineTo(pts[i].x, pts[i].y);
+ }
+ chartCtx.strokeStyle = '#ef4444';
+ chartCtx.lineWidth = 2.5;
+ chartCtx.setLineDash([4, 4]);
+ chartCtx.stroke();
+ chartCtx.setLineDash([]);
+
+ // Draw points
+ pts.forEach(pt => {
+ chartCtx.beginPath();
+ chartCtx.arc(pt.x, pt.y, 4, 0, 2*Math.PI);
+ chartCtx.fillStyle = '#ef4444';
+ chartCtx.fill();
+ chartCtx.strokeStyle = '#fff';
+ chartCtx.lineWidth = 1;
+ chartCtx.stroke();
+ });
+ }
+}
+
+
+// --- 4. Multiple Choice Quiz Engine ---
+const quizQuestions = [
+ {
+ question: "What does concentration parameter α control in a Dirichlet data split simulation?",
+ answers: [
+ "The model learning rate on the client devices.",
+ "The volume of local GPU memory allocated for Flower nodes.",
+ "The degree of label skew across client datasets. Low α creates extreme skew, while high α approaches IID splits."
+ ],
+ correctIdx: 2,
+ explanation: "Correct! The parameter α controls the concentration. When α is small, class samples are split highly unevenly (giving most of a class to just one client). As α gets larger, each client receives a uniform, representative sample of classes."
+ },
+ {
+ question: "What is the core optimizer mechanic behind the failure of FedAvg to converge quickly under Non-IID conditions?",
+ answers: [
+ "Objective Inconsistency / Client Drift. Local models optimize for local minima that push parameters in conflicting directions, washing out updates upon aggregation.",
+ "The central server's failure to weight parameter counts by sample volumes.",
+ "Excessive client network latency stalling gradient calculations."
+ ],
+ correctIdx: 0,
+ explanation: "Correct! Client drift is the core issue. Because datasets differ statistically, the gradient updates of client A and client B pull the shared parameters in opposite directions. The server average washes out this progress, leading to slow or oscillating convergence."
+ },
+ {
+ question: "Which skew is simulated when Client A holds primarily images of cats, and Client B holds primarily images of dogs?",
+ answers: [
+ "Feature distribution skew",
+ "Label distribution skew (or prior probability skew)",
+ "Quantity distribution skew"
+ ],
+ correctIdx: 1,
+ explanation: "Correct! Label distribution skew is when the marginal distribution of class labels P(y) varies across clients, which is the easiest skew to simulate and study."
+ },
+ {
+ question: "If alpha is set to 0.1, what data distribution would you expect on a client?",
+ answers: [
+ "A balanced mix of all 10 digits (IID uniform distribution).",
+ "An empty partition due to numerical errors.",
+ "A dataset dominated by 1 or 2 specific digits with almost zero samples of others."
+ ],
+ correctIdx: 2,
+ explanation: "Correct! Alpha = 0.1 is highly skewed Non-IID. Each client will get massive chunks of a couple classes and none of the rest."
+ }
+];
+
+let quizState = {
+ currentIdx: 0,
+ score: 0,
+ selectedIdx: null,
+ answered: false
+};
+
+function initQuiz() {
+ quizState.currentIdx = 0;
+ quizState.score = 0;
+ quizState.selectedIdx = null;
+ quizState.answered = false;
+ renderQuizQuestion();
+}
+
+function renderQuizQuestion() {
+ const card = document.getElementById('quiz-question-card');
+ const scoreDisplay = document.getElementById('quiz-score-display');
+ const nextBtn = document.getElementById('btn-next-question');
+
+ nextBtn.classList.add('hidden');
+ scoreDisplay.innerText = `Score: ${quizState.score} / ${quizQuestions.length}`;
+
+ if (quizState.currentIdx >= quizQuestions.length) {
+ card.innerHTML = `
+
+
🏆
+
Quiz Complete!
+
Your final score: ${quizState.score} out of ${quizQuestions.length}
+
Retake Quiz
+
+ `;
+ return;
+ }
+
+ const q = quizQuestions[quizState.currentIdx];
+
+ let answersHTML = q.answers.map((ans, idx) => {
+ return `
+
+ ${String.fromCharCode(65 + idx)}
+ ${ans}
+
+ `;
+ }).join('');
+
+ card.innerHTML = `
+ Question ${quizState.currentIdx + 1} of ${quizQuestions.length}
+ ${q.question}
+
+ ${answersHTML}
+
+
+ `;
+}
+
+function selectQuizAnswer(idx) {
+ if (quizState.answered) return;
+
+ quizState.selectedIdx = idx;
+ const q = quizQuestions[quizState.currentIdx];
+ quizState.answered = true;
+
+ const optEl = document.getElementById(`quiz-opt-${idx}`);
+ const expBox = document.getElementById('quiz-explanation');
+ const nextBtn = document.getElementById('btn-next-question');
+
+ expBox.innerHTML = `Explanation: ${q.explanation}`;
+ expBox.classList.remove('hidden');
+ nextBtn.classList.remove('hidden');
+
+ if (idx === q.correctIdx) {
+ optEl.classList.add('correct');
+ quizState.score++;
+ } else {
+ optEl.classList.add('wrong');
+ document.getElementById(`quiz-opt-${q.correctIdx}`).classList.add('correct');
+ }
+
+ const scoreDisplay = document.getElementById('quiz-score-display');
+ scoreDisplay.innerText = `Score: ${quizState.score} / ${quizQuestions.length}`;
+}
+
+function nextQuestion() {
+ quizState.currentIdx++;
+ quizState.selectedIdx = null;
+ quizState.answered = false;
+ renderQuizQuestion();
+}
+
+
+// --- App Initialization ---
+window.addEventListener('DOMContentLoaded', () => {
+ initDirichletPlayground();
+ initQuiz();
+});
diff --git a/week-3/walkthrough/index.html b/week-3/walkthrough/index.html
new file mode 100644
index 0000000..481cab4
--- /dev/null
+++ b/week-3/walkthrough/index.html
@@ -0,0 +1,310 @@
+
+
+
+
+
+ Federated Learning: Week 3 Walkthrough
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Dirichlet Configuration
+
Simulate label skew across clients. The Concentration Parameter (α) controls how data classes are distributed.
+
+
+
+
+
+
+ 0.1 (Extreme Skew)
+ 0.5 (Moderate Skew)
+ 3.0 (Near-IID)
+
+
+
+
+
Distribution State
+
+ Dirichlet distribution generates uneven ratios of MNIST classes (0-9) per client. Local updates will be biased.
+
+
+
+
+ 💡 Core Concept: Low α means clients have highly biased local datasets, forcing them to learn partial, skewed boundaries.
+
+
+
+
+
+
Client Class Distributions
+
+
Labels (MNIST Digits 0-9):
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Drift Controls
+
Simulate gradient steps in a 2D parameter space. Observe how client updates fight each other.
+
+
+ Local Training Epochs (E)
+
+
+
+
+
+
Data Skew (Client Drift Source)
+
+ IID (Aligns Loss)
+ Non-IID (Split Loss)
+
+
+
+
+
+ ▶ Simulate Round
+
+
+ ↺ Reset weights
+
+
+
+
+
Client Drift Magnitude
+
+ Client Drift: 0.00
+
+
+
+
+ ⚠️ Client Drift: When client models train on non-aligned loss landscapes (red & blue targets), they diverge. Averaging them yields an aggregated global model that may suffer from slower convergence.
+
+
+
+
+
+
+
+
+
+
Colored rings denote loss gradients. Dotted paths show clients optimizing on their unique datasets. Solid path shows aggregated global updates.
+
+
+
+
+
+
+
+
+
Step-by-Step Terminal Guide
+
Reproduce this Non-IID experiment locally on your own GPU/CPU. Follow these commands in your shell:
+
+
+
Step 1
+
+
Setup Environment
+
Clone or navigate into the week-3 workspace, initialize a virtual environment, and install PyTorch and Flower.
+
+
cd week-3
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
+
Copy
+
+
+
+
+
+
Step 2
+
+
Run Simulations
+
Execute the orchestrator. You can run the fast local PyTorch runner (Option A) or the standard Flower/Ray runner (Option B).
+
+
# Option A (Recommended): Pure PyTorch Simulation
python main.py
# Option B: Ray-backed Flower Simulation
python main_flower.py
+
Copy
+
+
+
+
+
+
Step 3
+
+
View and Compare Logs
+
The plotter script parses `iid_results.csv` and `noniid_results.csv` to overlay convergence curves. Run it manually if you update CSVs.
+
+
python plot.py
+
Copy
+
+
+
+
+
+
+
+
+
+
+
+ fellowship-student@decentralized: ~/week-3
+
+
+
$ python main.py
+
=== [Process 1/2] RUNNING IID SIMULATION ===
+
Starting IID Simulation (Rounds: 10, Clients: 5)
+
Round 1 | Accuracy: 0.7845 | Loss: 1.2210
+
Round 2 | Accuracy: 0.8491 | Loss: 0.6834
+
...
+
Round 10 | Accuracy: 0.9082 | Loss: 0.3541
+
=== [Process 2/2] RUNNING NON-IID SIMULATION ===
+
Starting NONIID Simulation (Rounds: 10, Clients: 5, alpha=0.5)
+
Round 1 | Accuracy: 0.3120 | Loss: 2.1102
+
Round 2 | Accuracy: 0.4498 | Loss: 1.8904
+
...
+
Round 10 | Accuracy: 0.7104 | Loss: 1.0504
+
=== GENERATING CONVERGENCE COMPARISON PLOT ===
+
Successfully created 'convergence_comparison.png'.
+
|
+
+
+
+
+
+
+
+
+
+
Plot Custom Logs
+
Upload your generated CSVs from your local training to plot them interactively.
+
+
+
+ 📤 Upload IID CSV
+
+
+ No file uploaded
+
+
+
+
+ 📤 Upload Non-IID CSV
+
+
+ No file uploaded
+
+
+
+ Load Default Log Data
+
+
+
+
Accuracy Discrepancy
+
+ Load results to compare.
+
+
+
+
+
+
+
Live Convergence Plot
+
+
+
+
+ IID Accuracy
+ Non-IID Accuracy
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/week-3/walkthrough/style.css b/week-3/walkthrough/style.css
new file mode 100644
index 0000000..3d2ec66
--- /dev/null
+++ b/week-3/walkthrough/style.css
@@ -0,0 +1,1005 @@
+/* --- Global Theme & Custom Variables --- */
+:root {
+ --bg-dark: #090d16;
+ --bg-surface: rgba(17, 25, 40, 0.75);
+ --border-color: rgba(255, 255, 255, 0.08);
+ --text-primary: #f3f4f6;
+ --text-secondary: #9ca3af;
+ --text-muted: #6b7280;
+
+ /* Neon Palettes */
+ --accent-blue: #3b82f6;
+ --accent-blue-glow: rgba(59, 130, 246, 0.35);
+ --accent-purple: #8b5cf6;
+ --accent-purple-glow: rgba(139, 92, 246, 0.35);
+ --accent-pink: #ec4899;
+ --accent-green: #10b981;
+ --accent-red: #ef4444;
+ --accent-orange: #f59e0b;
+
+ /* Fonts */
+ --font-heading: 'Outfit', 'Plus Jakarta Sans', sans-serif;
+ --font-body: 'Plus Jakarta Sans', sans-serif;
+ --font-mono: 'JetBrains Mono', monospace;
+}
+
+* {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+body {
+ background-color: var(--bg-dark);
+ color: var(--text-primary);
+ font-family: var(--font-body);
+ min-height: 100vh;
+ overflow-x: hidden;
+}
+
+/* Custom Scrollbar */
+::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+::-webkit-scrollbar-track {
+ background: var(--bg-dark);
+}
+::-webkit-scrollbar-thumb {
+ background: rgba(255, 255, 255, 0.1);
+ border-radius: 10px;
+}
+::-webkit-scrollbar-thumb:hover {
+ background: var(--accent-blue);
+}
+
+/* --- Layout Container --- */
+.app-container {
+ max-width: 1300px;
+ margin: 0 auto;
+ padding: 20px;
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+}
+
+/* --- Navigation Header --- */
+.app-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 15px 25px;
+ background: var(--bg-surface);
+ backdrop-filter: blur(16px);
+ -webkit-backdrop-filter: blur(16px);
+ border: 1px solid var(--border-color);
+ border-radius: 16px;
+ margin-bottom: 25px;
+ box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
+}
+
+.logo-area {
+ display: flex;
+ align-items: center;
+ gap: 15px;
+}
+
+.logo-icon {
+ font-size: 32px;
+ filter: drop-shadow(0 0 10px var(--accent-blue-glow));
+ animation: rotate-spinner 6s infinite linear;
+}
+
+@keyframes rotate-spinner {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+}
+
+.logo-text h1 {
+ font-family: var(--font-heading);
+ font-size: 20px;
+ font-weight: 700;
+ letter-spacing: -0.5px;
+ background: linear-gradient(135deg, #fff 0%, var(--text-secondary) 100%);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+}
+
+.logo-text p {
+ font-size: 11px;
+ color: var(--text-secondary);
+ font-weight: 500;
+}
+
+.app-nav {
+ display: flex;
+ gap: 8px;
+}
+
+.nav-btn {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ background: transparent;
+ border: none;
+ color: var(--text-secondary);
+ padding: 10px 16px;
+ border-radius: 10px;
+ font-family: var(--font-heading);
+ font-size: 13px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+.nav-icon {
+ width: 18px;
+ height: 18px;
+ fill: currentcolor;
+}
+
+.nav-btn:hover {
+ color: var(--text-primary);
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.nav-btn.active {
+ color: #fff;
+ background: linear-gradient(135deg, var(--accent-blue) 0%, var(--accent-purple) 100%);
+ box-shadow: 0 0 15px var(--accent-blue-glow);
+}
+
+/* --- Main content tabs --- */
+.tab-content {
+ display: none;
+ animation: fadeIn 0.4s ease-out forwards;
+}
+
+.tab-content.active {
+ display: block;
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; transform: translateY(8px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+/* --- Grid & Layouts --- */
+.content-grid-split {
+ display: grid;
+ grid-template-columns: 380px 1fr;
+ gap: 25px;
+ align-items: start;
+}
+
+/* --- Cards / Glass Panels --- */
+.glass {
+ background: var(--bg-surface);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ border: 1px solid var(--border-color);
+ border-radius: 16px;
+ padding: 24px;
+ box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.25);
+}
+
+.control-card {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.visualizer-card {
+ min-height: 520px;
+ display: flex;
+ flex-direction: column;
+}
+
+.flex-column-center {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+}
+
+h2 {
+ font-family: var(--font-heading);
+ font-size: 18px;
+ font-weight: 600;
+ margin-bottom: 8px;
+ letter-spacing: -0.3px;
+}
+
+.section-intro {
+ font-size: 13px;
+ color: var(--text-secondary);
+ line-height: 1.5;
+}
+
+/* --- Sliders & Controls --- */
+.slider-group, .control-group {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.slider-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.slider-label {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.slider-value {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ color: var(--accent-blue);
+ background: rgba(59, 130, 246, 0.1);
+ padding: 2px 8px;
+ border-radius: 6px;
+ font-weight: 600;
+}
+
+.control-hint {
+ font-size: 11px;
+ color: var(--text-muted);
+}
+
+input[type="range"] {
+ -webkit-appearance: none;
+ width: 100%;
+ height: 6px;
+ background: rgba(255, 255, 255, 0.08);
+ border-radius: 10px;
+ outline: none;
+}
+
+input[type="range"]::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, var(--accent-blue) 0%, var(--accent-purple) 100%);
+ cursor: pointer;
+ box-shadow: 0 0 8px var(--accent-blue-glow);
+ transition: transform 0.1s;
+}
+
+input[type="range"]::-webkit-slider-thumb:hover {
+ transform: scale(1.15);
+}
+
+.alpha-presets {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-top: 8px;
+}
+
+.preset-btn {
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ padding: 6px 10px;
+ color: var(--text-secondary);
+ font-size: 11px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.preset-btn:hover {
+ background: rgba(255, 255, 255, 0.06);
+ color: var(--text-primary);
+}
+
+.preset-btn.active {
+ background: rgba(59, 130, 246, 0.12);
+ border-color: var(--accent-blue);
+ color: var(--accent-blue);
+}
+
+/* --- Toggle buttons --- */
+.toggle-group {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ background: rgba(0, 0, 0, 0.2);
+ padding: 4px;
+ border-radius: 10px;
+ border: 1px solid var(--border-color);
+}
+
+.toggle-btn {
+ background: transparent;
+ border: none;
+ color: var(--text-secondary);
+ padding: 8px;
+ border-radius: 6px;
+ font-size: 12px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.toggle-btn.active {
+ background: rgba(255, 255, 255, 0.08);
+ color: #fff;
+}
+
+/* --- Buttons --- */
+.btn-group {
+ display: flex;
+ gap: 10px;
+}
+
+.btn {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ border: none;
+ border-radius: 10px;
+ padding: 12px;
+ font-family: var(--font-heading);
+ font-size: 13px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+.btn-primary {
+ background: linear-gradient(135deg, var(--accent-blue) 0%, var(--accent-purple) 100%);
+ color: #fff;
+ box-shadow: 0 4px 15px rgba(139, 92, 246, 0.2);
+}
+
+.btn-primary:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 6px 20px rgba(139, 92, 246, 0.35);
+}
+
+.btn-secondary {
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid var(--border-color);
+ color: var(--text-primary);
+}
+
+.btn-secondary:hover {
+ background: rgba(255, 255, 255, 0.08);
+}
+
+.btn-danger {
+ background: rgba(239, 68, 68, 0.1);
+ border: 1px solid rgba(239, 68, 68, 0.2);
+ color: var(--accent-red);
+}
+
+.btn-danger:hover {
+ background: rgba(239, 68, 68, 0.18);
+}
+
+/* --- Stat and Alert boxes --- */
+.stat-box {
+ background: rgba(0, 0, 0, 0.2);
+ border: 1px solid var(--border-color);
+ border-radius: 12px;
+ padding: 14px 18px;
+}
+
+.stat-title {
+ font-size: 11px;
+ color: var(--text-muted);
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-bottom: 4px;
+}
+
+.stat-body {
+ font-size: 13px;
+ color: var(--text-primary);
+ line-height: 1.4;
+}
+
+.alert-box {
+ border-left: 3px solid;
+ padding: 12px 16px;
+ border-radius: 0 8px 8px 0;
+ font-size: 12px;
+ line-height: 1.5;
+}
+
+.alert-box.tip {
+ background: rgba(16, 185, 129, 0.06);
+ border-color: var(--accent-green);
+ color: rgba(16, 185, 129, 0.95);
+}
+
+.alert-box.warning {
+ background: rgba(245, 158, 11, 0.06);
+ border-color: var(--accent-orange);
+ color: rgba(245, 158, 11, 0.95);
+}
+
+/* --- Dirichlet Splitting Visualizer Elements --- */
+.class-legend {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ background: rgba(0, 0, 0, 0.15);
+ padding: 12px 16px;
+ border-radius: 10px;
+ margin-bottom: 20px;
+ border: 1px solid var(--border-color);
+}
+
+.legend-title {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-secondary);
+}
+
+.legend-colors {
+ display: flex;
+ justify-content: space-between;
+ gap: 4px;
+}
+
+.legend-item-chip {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 4px;
+ font-size: 11px;
+ font-weight: bold;
+ font-family: var(--font-mono);
+}
+
+.color-bar-indicator {
+ width: 100%;
+ height: 6px;
+ border-radius: 3px;
+}
+
+.clients-dist-container {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+ flex: 1;
+}
+
+.client-row {
+ display: grid;
+ grid-template-columns: 80px 1fr;
+ align-items: center;
+ gap: 15px;
+}
+
+.client-label {
+ font-family: var(--font-heading);
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.client-bars {
+ display: flex;
+ height: 36px;
+ border-radius: 8px;
+ overflow: hidden;
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid var(--border-color);
+ box-shadow: inset 0 2px 4px rgba(0,0,0,0.5);
+ transition: all 0.3s;
+}
+
+.bar-segment {
+ height: 100%;
+ transition: width 0.4s cubic-bezier(0.2, 0.8, 0.2, 1);
+ position: relative;
+ cursor: pointer;
+}
+
+.bar-segment:hover::after {
+ content: attr(data-percent);
+ position: absolute;
+ bottom: 110%;
+ left: 50%;
+ transform: translateX(-50%);
+ background: #000;
+ color: #fff;
+ font-size: 10px;
+ padding: 3px 6px;
+ border-radius: 4px;
+ white-space: nowrap;
+ z-index: 10;
+ font-family: var(--font-mono);
+}
+
+/* --- Client Drift Canvas Elements --- */
+.canvas-header {
+ width: 100%;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 12px;
+}
+
+.landscape-legend {
+ display: flex;
+ gap: 12px;
+ font-size: 11px;
+ font-weight: 600;
+}
+
+.legend-item {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.dot {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+}
+
+.dot.global-min { background: var(--accent-green); }
+.dot.client-min-1 { background: var(--accent-blue); }
+.dot.client-min-2 { background: var(--accent-red); }
+.dot.client-min-3 { background: var(--accent-pink); }
+
+.canvas-container {
+ width: 100%;
+ background: rgba(0, 0, 0, 0.35);
+ border-radius: 12px;
+ border: 1px solid var(--border-color);
+ position: relative;
+ overflow: hidden;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ box-shadow: inset 0 0 20px rgba(0, 0, 0, 0.6);
+}
+
+canvas {
+ display: block;
+}
+
+.canvas-caption {
+ margin-top: 12px;
+ font-size: 11px;
+ color: var(--text-secondary);
+ text-align: center;
+ max-width: 500px;
+}
+
+/* --- Terminal Tutorial Layout --- */
+.tutorial-layout {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 25px;
+ align-items: stretch;
+}
+
+.tutorial-text {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.step-card {
+ display: flex;
+ gap: 15px;
+ background: rgba(255, 255, 255, 0.015);
+ border: 1px solid var(--border-color);
+ padding: 16px;
+ border-radius: 12px;
+}
+
+.step-badge {
+ background: rgba(59, 130, 246, 0.1);
+ color: var(--accent-blue);
+ border: 1px solid rgba(59, 130, 246, 0.2);
+ border-radius: 8px;
+ padding: 4px 10px;
+ font-size: 11px;
+ font-weight: 700;
+ height: fit-content;
+ white-space: nowrap;
+}
+
+.step-details h3 {
+ font-family: var(--font-heading);
+ font-size: 14px;
+ font-weight: 600;
+ margin-bottom: 4px;
+}
+
+.step-details p {
+ font-size: 12px;
+ color: var(--text-secondary);
+ line-height: 1.4;
+ margin-bottom: 12px;
+}
+
+.terminal-command-container {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ background: rgba(0, 0, 0, 0.4);
+ border: 1px solid rgba(255, 255, 255, 0.05);
+ border-radius: 8px;
+ padding: 12px 14px;
+ position: relative;
+}
+
+.terminal-command {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ color: #e5e7eb;
+ line-height: 1.5;
+ white-space: pre-wrap;
+ word-break: break-all;
+}
+
+.copy-btn {
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ color: var(--text-secondary);
+ padding: 4px 8px;
+ font-size: 10px;
+ cursor: pointer;
+ font-weight: 600;
+ transition: all 0.2s;
+ user-select: none;
+}
+
+.copy-btn:hover {
+ background: var(--accent-blue);
+ border-color: var(--accent-blue);
+ color: #fff;
+}
+
+/* Mock Terminal Window */
+.terminal-window {
+ background: #060913;
+ border: 1px solid rgba(255, 255, 255, 0.07);
+ border-radius: 12px;
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
+ display: flex;
+ flex-direction: column;
+ min-height: 480px;
+}
+
+.terminal-titlebar {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 12px 16px;
+ background: rgba(0, 0, 0, 0.3);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.04);
+}
+
+.terminal-titlebar .dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+}
+
+.terminal-titlebar .dot.red { background: #ff5f56; }
+.terminal-titlebar .dot.yellow { background: #ffbd2e; }
+.terminal-titlebar .dot.green { background: #27c93f; }
+
+.terminal-titlebar .title {
+ font-size: 11px;
+ color: var(--text-secondary);
+ font-weight: 500;
+ margin-left: 10px;
+}
+
+.terminal-body {
+ flex: 1;
+ padding: 20px;
+ font-size: 12px;
+ line-height: 1.6;
+ color: #e5e7eb;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.output-dim {
+ color: var(--text-muted);
+}
+
+.output-success {
+ color: var(--accent-green);
+ font-weight: 600;
+}
+
+.terminal-cursor {
+ animation: blink 1s step-end infinite;
+ color: var(--accent-blue);
+ font-weight: bold;
+}
+
+@keyframes blink {
+ 50% { opacity: 0; }
+}
+
+/* --- Results Chart Tab Elements --- */
+.upload-group {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ background: rgba(0, 0, 0, 0.15);
+ padding: 14px;
+ border-radius: 10px;
+ border: 1px solid var(--border-color);
+}
+
+.upload-btn {
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ padding: 10px;
+ text-align: center;
+ color: var(--text-primary);
+ font-size: 12px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.upload-btn:hover {
+ background: rgba(59, 130, 246, 0.08);
+ border-color: var(--accent-blue);
+}
+
+.upload-btn input[type="file"] {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+.upload-btn:focus-within {
+ outline: 2px solid var(--accent-blue);
+ outline-offset: 2px;
+}
+
+.file-name-label {
+ font-size: 10px;
+ color: var(--text-muted);
+ font-family: var(--font-mono);
+ text-align: center;
+}
+
+.chart-legend {
+ display: flex;
+ justify-content: center;
+ gap: 20px;
+ margin-top: 15px;
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.chart-legend .line {
+ display: inline-block;
+ width: 20px;
+ height: 3px;
+ vertical-align: middle;
+ margin-right: 6px;
+ border-radius: 2px;
+}
+
+.chart-legend .line.iid-line { background: var(--accent-blue); }
+.chart-legend .line.noniid-line {
+ background: var(--accent-red);
+ border-top: 2px dashed #000; /* mimic dashed line */
+}
+
+/* --- Quiz Tab Elements --- */
+.quiz-container {
+ max-width: 700px;
+ margin: 40px auto;
+}
+
+.quiz-header {
+ text-align: center;
+ margin-bottom: 24px;
+}
+
+.quiz-header p {
+ font-size: 13px;
+ color: var(--text-secondary);
+}
+
+.quiz-card {
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+}
+
+.quiz-question-num {
+ font-size: 11px;
+ color: var(--accent-purple);
+ font-weight: bold;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+}
+
+.quiz-question-text {
+ font-family: var(--font-heading);
+ font-size: 16px;
+ font-weight: 600;
+ line-height: 1.5;
+}
+
+.quiz-answers-list {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.quiz-option {
+ background: rgba(255, 255, 255, 0.025);
+ border: 1px solid var(--border-color);
+ border-radius: 10px;
+ padding: 14px 18px;
+ color: var(--text-primary);
+ font-size: 13px;
+ line-height: 1.4;
+ cursor: pointer;
+ transition: all 0.2s;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.quiz-option:hover {
+ background: rgba(255, 255, 255, 0.05);
+ border-color: rgba(255, 255, 255, 0.15);
+}
+
+.quiz-option.selected {
+ background: rgba(139, 92, 246, 0.08);
+ border-color: var(--accent-purple);
+}
+
+.quiz-option.correct {
+ background: rgba(16, 185, 129, 0.08);
+ border-color: var(--accent-green);
+ color: #10b981;
+}
+
+.quiz-option.wrong {
+ background: rgba(239, 68, 68, 0.08);
+ border-color: var(--accent-red);
+ color: #ef4444;
+}
+
+.option-marker {
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ border: 2px solid var(--text-muted);
+ font-family: var(--font-heading);
+ font-size: 11px;
+ font-weight: bold;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.quiz-option.selected .option-marker {
+ border-color: var(--accent-purple);
+ background: var(--accent-purple);
+ color: #fff;
+}
+
+.quiz-option.correct .option-marker {
+ border-color: var(--accent-green);
+ background: var(--accent-green);
+ color: #fff;
+}
+
+.quiz-option.wrong .option-marker {
+ border-color: var(--accent-red);
+ background: var(--accent-red);
+ color: #fff;
+}
+
+.quiz-explanation-box {
+ margin-top: 14px;
+ background: rgba(139, 92, 246, 0.04);
+ border: 1px solid rgba(139, 92, 246, 0.1);
+ border-left: 3px solid var(--accent-purple);
+ padding: 14px 18px;
+ border-radius: 0 8px 8px 0;
+ font-size: 13px;
+ line-height: 1.5;
+ animation: slideDown 0.3s ease-out forwards;
+}
+
+@keyframes slideDown {
+ from { opacity: 0; transform: translateY(-5px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+.quiz-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-top: 25px;
+ border-top: 1px solid var(--border-color);
+ padding-top: 20px;
+}
+
+.score-display {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-secondary);
+}
+
+.font-mono {
+ font-family: var(--font-mono);
+}
+
+.hidden {
+ display: none !important;
+}
+
+/* --- Font Awesome/SVG helper rules --- */
+svg {
+ display: inline-block;
+ vertical-align: middle;
+}
+
+/* --- Responsive Adjustments --- */
+@media (max-width: 1024px) {
+ .tutorial-layout {
+ grid-template-columns: 1fr;
+ }
+
+ .content-grid-split {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (max-width: 768px) {
+ .app-header {
+ flex-direction: column;
+ gap: 15px;
+ align-items: center;
+ text-align: center;
+ }
+
+ .app-nav {
+ flex-wrap: wrap;
+ justify-content: center;
+ }
+
+ .visualizer-card canvas,
+ #results-chart-canvas {
+ max-width: 100%;
+ height: auto;
+ }
+}