Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
6194474
feat(week-3): add non-iid simulation, analysis memo, and interactive …
koleajeolayinka Jul 15, 2026
00ce566
Merge branch 'main' of github.com:Flow-Research/decentralized-trainin…
koleajeolayinka Jul 15, 2026
69f6c98
fix(fed): improve tf stub compatibility check on broken imports
koleajeolayinka Jul 15, 2026
e38a006
refactor(fed): evaluate on validation dataset loader and fix loss ave…
koleajeolayinka Jul 15, 2026
63761b5
feat(fed): support partitioned train/test splits for IID and Non-IID …
koleajeolayinka Jul 15, 2026
115ec6f
feat(main): load separate train/validation splits and evaluate global…
koleajeolayinka Jul 15, 2026
aa03546
fix(flower): redirect ray/flower tmp directories using unique system …
koleajeolayinka Jul 15, 2026
43f664a
feat(plot): support customization of alpha parameter in comparative p…
koleajeolayinka Jul 15, 2026
2b55504
chore: replace matplotlib with Pillow in requirements to reduce packa…
koleajeolayinka Jul 15, 2026
6ae68a8
docs(walkthrough): add client 3 legend color and clarify CLI options …
koleajeolayinka Jul 15, 2026
d71a626
fix(walkthrough): resolve edge-case divisions, enhance accessibility …
koleajeolayinka Jul 15, 2026
60b5d40
style(walkthrough): add mobile responsiveness media-queries and fix s…
koleajeolayinka Jul 15, 2026
668c54e
docs(memo): update convergence metrics with exact numbers from correc…
koleajeolayinka Jul 15, 2026
b8b358f
docs(readme): clarify options for pure PyTorch and Ray-backed Flower …
koleajeolayinka Jul 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions week-3/.gitignore
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions week-3/MEMO.md
Original file line number Diff line number Diff line change
@@ -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?
128 changes: 128 additions & 0 deletions week-3/README.md
Original file line number Diff line number Diff line change
@@ -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/
```
1 change: 1 addition & 0 deletions week-3/fed/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Expose the fed package.
52 changes: 52 additions & 0 deletions week-3/fed/_compat.py
Original file line number Diff line number Diff line change
@@ -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
66 changes: 66 additions & 0 deletions week-3/fed/client.py
Original file line number Diff line number Diff line change
@@ -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()
Loading