Edge Server Placement — Bias Analysis, Augmentation & Algorithm Comparison¶

Author: Saneha Gill | Dataset: Optus Melbourne CBD

1. Setup¶

In [1]:
!pip install scikit-learn scipy seaborn --quiet

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from math import radians, sin, cos, sqrt, atan2
from scipy.stats import skew, kurtosis
from scipy.spatial import cKDTree
from sklearn.neighbors import KernelDensity
from scipy import stats
import warnings
warnings.filterwarnings('ignore')

SEED            = 42
np.random.seed(SEED)
MAX_COVERAGE_KM = 3.0
SERVER_CAPACITY = 50

plt.rcParams['font.size']      = 11
plt.rcParams['axes.grid']      = True
plt.rcParams['grid.alpha']     = 0.3
plt.rcParams['figure.facecolor'] = 'white'

def haversine(lat1, lon1, lat2, lon2):
    R = 6371
    dlat, dlon = radians(lat2-lat1), radians(lon2-lon1)
    a = sin(dlat/2)**2 + cos(radians(lat1))*cos(radians(lat2))*sin(dlon/2)**2
    return R * 2 * atan2(sqrt(a), sqrt(1-a))

print("Setup complete.")
Setup complete.

2. Load Data¶

In [3]:
# Update DATA_FOLDER to match your Drive path
DATA_FOLDER = "/content/drive/MyDrive"
# from google.colab import drive; drive.mount('/content/drive')

servers     = pd.read_csv(f"{DATA_FOLDER}/site-optus-melbCBD.csv")
cbd_users   = pd.read_csv(f"{DATA_FOLDER}/users-melbcbd-generated.csv")
metro_users = pd.read_csv(f"{DATA_FOLDER}/users-melbmetro-generated.csv")

TOTAL_CAPACITY = len(servers) * SERVER_CAPACITY

print(f"Servers:      {len(servers):>7,}  |  cols: {list(servers.columns[:4])}")
print(f"CBD users:    {len(cbd_users):>7,}  |  cols: {list(cbd_users.columns)}")
print(f"Metro users:  {len(metro_users):>7,}  |  cols: {list(metro_users.columns)}")
print(f"Total capacity: {TOTAL_CAPACITY:,} users")
Servers:          125  |  cols: ['SITE_ID', 'LATITUDE', 'LONGITUDE', 'NAME']
CBD users:        816  |  cols: ['Latitude', 'Longitude']
Metro users:  131,312  |  cols: ['Latitude', 'Longitude']
Total capacity: 6,250 users

3. Bias Analysis¶

In [2]:
from google.colab import drive
drive.mount('/content/drive')
Mounted at /content/drive
In [10]:
def min_dist_to_servers(users_df, servers_df, lat_col='Latitude',
                        lon_col='Longitude', sample_n=None):
    df = users_df.sample(sample_n, random_state=SEED) if sample_n else users_df
    return [
        min(haversine(u[lat_col], u[lon_col], s['LATITUDE'], s['LONGITUDE'])
            for _, s in servers_df.iterrows())
        for _, u in df.iterrows()
    ]

def bbox_km(df, lat, lon):
    la = (df[lat].max() - df[lat].min()) * 111
    lo = (df[lon].max() - df[lon].min()) * 111
    return la, lo, la*lo

# ── Bias 1: size imbalance ───────────────────────────────────────────────────
print("BIAS 1 — Size Imbalance")
print(f"  CBD users:    {len(cbd_users):>8,}")
print(f"  Metro users:  {len(metro_users):>8,}  ({len(metro_users)//len(cbd_users)}x larger)")
print(f"  Servers:      {len(servers):>8,}")

# ── Bias 2: geographic gap ───────────────────────────────────────────────────
print("\nBIAS 2 — Geographic Coverage Gap")
for name, df, lat, lon in [
    ('Servers',    servers,     'LATITUDE','LONGITUDE'),
    ('CBD users',  cbd_users,   'Latitude','Longitude'),
    ('Metro users',metro_users, 'Latitude','Longitude'),
]:
    la, lo, ar = bbox_km(df, lat, lon)
    print(f"  {name:<12}  {la:6.1f} km x {lo:6.1f} km  = {ar:8.1f} km²")

# ── Bias 3: reachability ─────────────────────────────────────────────────────
print("\nBIAS 3 — Server Reachability Gap  (Metro sample n=2000)")
cbd_dists   = min_dist_to_servers(cbd_users, servers)
metro_dists = min_dist_to_servers(metro_users, servers, sample_n=2000)
cbd_cov   = sum(d <= MAX_COVERAGE_KM for d in cbd_dists)
metro_cov = sum(d <= MAX_COVERAGE_KM for d in metro_dists)
print(f"  CBD   within 3km: {cbd_cov}/{len(cbd_dists)}  = {100*cbd_cov/len(cbd_dists):.1f}%  avg {np.mean(cbd_dists):.3f} km")
print(f"  Metro within 3km: {metro_cov}/2000  = {100*metro_cov/2000:.1f}%  avg {np.mean(metro_dists):.1f} km")

# ── Bias 4: CBD density ──────────────────────────────────────────────────────
print("\nBIAS 4 — CBD Spatial Density (10x10 grid)")
H, *_ = np.histogram2d(cbd_users['Latitude'], cbd_users['Longitude'], bins=10)
print(f"  Empty cells: {int((H==0).sum())}/100  |  min {int(H[H>0].min())}  max {int(H.max())}  CoV {H[H>0].std()/H[H>0].mean()*100:.0f}%")

# ── Bias 5: Metro skewness ───────────────────────────────────────────────────
print("\nBIAS 5 — Metro Latitude Skewness")
print(f"  Skewness: {skew(metro_users['Latitude']):.4f}  (CBD: {skew(cbd_users['Latitude']):.4f})")
print(f"  Kurtosis: {kurtosis(metro_users['Latitude']):.4f}")

# ── Bias 6: distance mismatch ────────────────────────────────────────────────
print("\nBIAS 6 — Distance Distribution Mismatch")
print(f"  CBD mean:   {np.mean(cbd_dists):.3f} km  |  Metro mean: {np.mean(metro_dists):.1f} km")
print(f"  Ratio: {np.mean(metro_dists)/np.mean(cbd_dists):.0f}x")

# ── Bias 7: server clustering ────────────────────────────────────────────────
print("\nBIAS 7 — Server Clustering")
sc = servers[['LATITUDE','LONGITUDE']].values
inter = [haversine(sc[i][0],sc[i][1],sc[j][0],sc[j][1])
         for i in range(len(sc)) for j in range(i+1,len(sc))]
print(f"  Min: {np.min(inter)*1000:.0f} m  |  Mean: {np.mean(inter):.3f} km  |  Pairs < 200m: {sum(d<0.2 for d in inter)}")

# ── Bias 8: capacity ─────────────────────────────────────────────────────────
print("\nBIAS 8 — Capacity vs Demand")
print(f"  Total capacity: {TOTAL_CAPACITY:,}  |  Full Metro: {len(metro_users):,}  ({len(metro_users)/TOTAL_CAPACITY:.1f}x over)")

# ── Bias 9: temporal ─────────────────────────────────────────────────────────
print("\nBIAS 9 — No Temporal Dimension")
print(f"  CBD columns:   {list(cbd_users.columns)}  — no timestamps")
print(f"  Metro columns: {list(metro_users.columns)}  — no timestamps")
BIAS 1 — Size Imbalance
  CBD users:         816
  Metro users:   131,312  (160x larger)
  Servers:           125

BIAS 2 — Geographic Coverage Gap
  Servers          1.3 km x    2.5 km  =      3.3 km²
  CBD users        1.4 km x    2.5 km  =      3.6 km²
  Metro users    118.4 km x  140.2 km  =  16594.3 km²

BIAS 3 — Server Reachability Gap  (Metro sample n=2000)
  CBD   within 3km: 816/816  = 100.0%  avg 0.065 km
  Metro within 3km: 260/2000  = 13.0%  avg 17.7 km

BIAS 4 — CBD Spatial Density (10x10 grid)
  Empty cells: 27/100  |  min 1  max 25  CoV 44%

BIAS 5 — Metro Latitude Skewness
  Skewness: -1.2381  (CBD: -0.0524)
  Kurtosis: 3.0626

BIAS 6 — Distance Distribution Mismatch
  CBD mean:   0.065 km  |  Metro mean: 17.7 km
  Ratio: 272x

BIAS 7 — Server Clustering
  Min: 10 m  |  Mean: 0.765 km  |  Pairs < 200m: 432

BIAS 8 — Capacity vs Demand
  Total capacity: 6,250  |  Full Metro: 131,312  (21.0x over)

BIAS 9 — No Temporal Dimension
  CBD columns:   ['Latitude', 'Longitude']  — no timestamps
  Metro columns: ['Latitude', 'Longitude']  — no timestamps
In [11]:
bias_summary = [
    ("1","Size Imbalance",          "Metro 161x CBD",                   "High",   "Suburban bridging dataset"),
    ("2","Geographic Gap",          "Servers 0.02% of Metro area",      "High",   "Expand servers to suburbs"),
    ("3","Reachability Gap",        "87% Metro users beyond 3km",       "High",   "Add suburban servers"),
    ("4","CBD Density Uneven",      "22/100 grid cells empty, CoV 75%", "High",   "KDE-based generation"),
    ("5","Metro Lat Skewness",      "Skewness -1.24",                   "Medium", "Population-weighted sampling"),
    ("6","Distance Mismatch",       "CBD 0.065 km vs Metro 17.7 km",    "Medium", "Unified dataset"),
    ("7","Server Clustering",       "Min gap 10 m",                     "Medium", "300 m deduplication"),
    ("8","Capacity vs Demand",      "Metro = 21x capacity",             "Medium", "Scale servers"),
    ("9","No Temporal Dimension",   "No timestamps",                    "Low",    "Time-slot simulation"),
]
df_bias = pd.DataFrame(bias_summary, columns=["#","Bias","Evidence","Priority","Fix"])
print(df_bias.to_string(index=False))
#                  Bias                         Evidence Priority                          Fix
1        Size Imbalance                   Metro 161x CBD     High    Suburban bridging dataset
2        Geographic Gap      Servers 0.02% of Metro area     High    Expand servers to suburbs
3      Reachability Gap       87% Metro users beyond 3km     High         Add suburban servers
4    CBD Density Uneven 22/100 grid cells empty, CoV 75%     High         KDE-based generation
5    Metro Lat Skewness                   Skewness -1.24   Medium Population-weighted sampling
6     Distance Mismatch    CBD 0.065 km vs Metro 17.7 km   Medium              Unified dataset
7     Server Clustering                     Min gap 10 m   Medium          300 m deduplication
8    Capacity vs Demand             Metro = 21x capacity   Medium                Scale servers
9 No Temporal Dimension                    No timestamps      Low         Time-slot simulation
In [12]:
fig, axes = plt.subplots(2, 3, figsize=(16, 9))
fig.suptitle('Bias Analysis — Melbourne Edge Server Dataset', fontsize=14, fontweight='bold')

# 1. Size
ax = axes[0,0]
labels, counts = ['CBD Users','Metro Users','Servers'], [len(cbd_users),len(metro_users),len(servers)]
ax.bar(labels, counts, color=['#4C72B0','#4C72B0','#888'], edgecolor='black')
ax.set_yscale('log'); ax.set_title('Bias 1 — Size Imbalance (log)'); ax.set_ylabel('Records')
for bar, c in zip(ax.patches, counts):
    ax.text(bar.get_x()+bar.get_width()/2, c*1.4, f'{c:,}', ha='center', fontsize=9)

# 2. Coverage gap bar
ax = axes[0,1]
names = ['Servers','CBD','Metro']
lat_spans = [(servers['LATITUDE'].max()-servers['LATITUDE'].min())*111,
             (cbd_users['Latitude'].max()-cbd_users['Latitude'].min())*111,
             (metro_users['Latitude'].max()-metro_users['Latitude'].min())*111]
ax.barh(names, lat_spans, color=['#888','#4C72B0','#4C72B0'], edgecolor='black')
ax.set_title('Bias 2 — Lat Span (km)'); ax.set_xlabel('km')

# 3. Reachability stacked
ax = axes[0,2]
cbd_in, metro_in = cbd_cov, metro_cov
cbd_out, metro_out = len(cbd_dists)-cbd_in, 2000-metro_in
ax.bar(['CBD','Metro'], [cbd_in, metro_in], label='Within 3km', color='#55A868', edgecolor='black')
ax.bar(['CBD','Metro'], [cbd_out, metro_out], bottom=[cbd_in,metro_in], label='Beyond 3km', color='#C44E52', edgecolor='black')
ax.set_title('Bias 3 — Reachability'); ax.set_ylabel('Users'); ax.legend()

# 4. CBD density heatmap
ax = axes[1,0]
im = ax.imshow(H, cmap='YlOrRd', aspect='auto', origin='lower')
plt.colorbar(im, ax=ax, label='Users per cell')
ax.set_title(f'Bias 4 — CBD Density (empty: {int((H==0).sum())}/100)')

# 5. Metro lat distribution
ax = axes[1,1]
ax.hist(metro_users['Latitude'], bins=60, color='#4C72B0', edgecolor='black', alpha=0.8, density=True)
ax.axvline(metro_users['Latitude'].mean(), color='red', lw=2, label=f"Mean {metro_users['Latitude'].mean():.3f}")
ax.set_title(f"Bias 5 — Metro Lat Skew ({skew(metro_users['Latitude']):.2f})")
ax.legend()

# 6. Distance distributions
ax = axes[1,2]
ax.hist(cbd_dists, bins=30, alpha=0.7, density=True, color='#4C72B0', label=f'CBD (mean {np.mean(cbd_dists):.3f} km)', edgecolor='black')
ax.hist(metro_dists, bins=30, alpha=0.6, density=True, color='#C44E52', label=f'Metro (mean {np.mean(metro_dists):.1f} km)', edgecolor='black')
ax.axvline(MAX_COVERAGE_KM, color='black', lw=2, ls='--', label='3km limit')
ax.set_title('Bias 6 — Distance Mismatch'); ax.legend(fontsize=8)

plt.tight_layout()
plt.savefig('bias_analysis.png', dpi=120, bbox_inches='tight')
plt.show()
No description has been provided for this image

4. Create Augmented Users¶

In [13]:
# ── KDE-based CBD users (fixes Bias 4) ──────────────────────────────────────
N_CBD_AUG = 3000
kde = KernelDensity(kernel='gaussian', bandwidth=0.002)
kde.fit(cbd_users[['Latitude','Longitude']].values)
np.random.seed(SEED)
aug_coords = kde.sample(N_CBD_AUG)
augmented_cbd = pd.DataFrame(aug_coords, columns=['Latitude','Longitude'])
augmented_cbd = augmented_cbd[
    (augmented_cbd['Latitude']  >= cbd_users['Latitude'].min()  - 0.005) &
    (augmented_cbd['Latitude']  <= cbd_users['Latitude'].max()  + 0.005) &
    (augmented_cbd['Longitude'] >= cbd_users['Longitude'].min() - 0.005) &
    (augmented_cbd['Longitude'] <= cbd_users['Longitude'].max() + 0.005)
].reset_index(drop=True)
augmented_cbd['source'] = 'cbd_kde'

# ── Gaussian suburban clusters (fixes Biases 1 & 6) ─────────────────────────
suburb_clusters = [
    # (name,                   lat,       lon,      n,    spread)
    ('Fitzroy/Collingwood',  -37.7990, 144.9780, 1200, 0.008),
    ('Richmond/Cremorne',    -37.8180, 145.0050, 1100, 0.007),
    ('South Yarra/Prahran',  -37.8390, 144.9930, 1000, 0.009),
    ('St Kilda',             -37.8620, 144.9800,  900, 0.010),
    ('Footscray',            -37.8010, 144.8990,  800, 0.008),
    ('Carlton/Parkville',    -37.7870, 144.9650,  700, 0.007),
    ('Docklands/W.Melbourne',-37.8150, 144.9420,  600, 0.006),
    ('Southbank/S.Melbourne',-37.8290, 144.9640, 1000, 0.007),
    ('Brunswick',            -37.7680, 144.9620,  700, 0.009),
    ('Hawthorn/Camberwell',  -37.8230, 145.0300,  800, 0.010),
    ('Northcote/Preston',    -37.7720, 145.0000,  600, 0.010),
    ('Port Melbourne',       -37.8370, 144.9290,  700, 0.008),
    ('Kensington/Flemington',-37.7920, 144.9260,  500, 0.008),
    ('Windsor/Armadale',     -37.8520, 145.0060,  700, 0.008),
    ('Abbotsford/Clifton H.',-37.8060, 145.0000,  600, 0.007),
]

rows = []
for name, lat, lon, n, spread in suburb_clusters:
    lats = np.random.normal(lat, spread, n)
    lons = np.random.normal(lon, spread, n)
    for la, lo in zip(lats, lons):
        rows.append({'Latitude': la, 'Longitude': lo, 'source': name})
suburban_df = pd.DataFrame(rows)

# ── Unified augmented user set ───────────────────────────────────────────────
all_aug_users = pd.concat([
    augmented_cbd[['Latitude','Longitude','source']],
    suburban_df[['Latitude','Longitude','source']],
], ignore_index=True)

print(f"KDE CBD users:    {len(augmented_cbd):>6,}")
print(f"Suburban users:   {len(suburban_df):>6,}")
print(f"Total aug users:  {len(all_aug_users):>6,}")
KDE CBD users:     2,999
Suburban users:   11,900
Total aug users:  14,899

5. Create Expanded Servers¶

In [14]:
def deduplicate_servers(df, min_sep_km=0.3):
    kept = []
    for i, row in df.iterrows():
        if not any(haversine(row['LATITUDE'], row['LONGITUDE'],
                             df.loc[k,'LATITUDE'], df.loc[k,'LONGITUDE']) < min_sep_km
                   for k in kept):
            kept.append(i)
    return df.loc[kept].reset_index(drop=True)

deduped = deduplicate_servers(servers, min_sep_km=0.3)
deduped['type'] = 'cbd'

# Suburban servers at each cluster centroid, 1.5km minimum spacing
sub_srvs, accepted, sid = [], [], 1000
for name, lat, lon, n, _ in suburb_clusters:
    n_srvs = max(1, n // 600)
    for k in range(n_srvs):
        np.random.seed(SEED + sid)
        slat = lat + np.random.normal(0, 0.004)
        slon = lon + np.random.normal(0, 0.004)
        existing = deduped[['LATITUDE','LONGITUDE']].values.tolist() + accepted
        if not any(haversine(slat, slon, c[0], c[1]) < 1.5 for c in existing):
            accepted.append([slat, slon])
            sub_srvs.append({'SITE_ID': f'SUB_{sid}', 'LATITUDE': slat, 'LONGITUDE': slon, 'type': 'suburban'})
        sid += 1

sub_srv_df = pd.DataFrame(sub_srvs)
expanded_servers = pd.concat([
    deduped[['SITE_ID','LATITUDE','LONGITUDE','type']],
    sub_srv_df[['SITE_ID','LATITUDE','LONGITUDE','type']],
], ignore_index=True)
expanded_servers['capacity']     = SERVER_CAPACITY
expanded_servers['current_load'] = 0
expanded_servers['active']       = False

print(f"Original CBD servers:   {len(servers)}")
print(f"Deduplicated CBD:       {len(deduped)}")
print(f"Suburban servers added: {len(sub_srv_df)}")
print(f"Total expanded:         {len(expanded_servers)}")
print(f"Total capacity:         {len(expanded_servers)*SERVER_CAPACITY:,}")
Original CBD servers:   125
Deduplicated CBD:       16
Suburban servers added: 12
Total expanded:         28
Total capacity:         1,400

6. Validate Augmentation¶

In [15]:
# Reachability on augmented users (sample for speed)
sample_aug = all_aug_users.sample(1000, random_state=SEED)
aug_dists = [
    min(haversine(u['Latitude'], u['Longitude'], s['LATITUDE'], s['LONGITUDE'])
        for _, s in expanded_servers.iterrows())
    for _, u in sample_aug.iterrows()
]
aug_cov = sum(d <= MAX_COVERAGE_KM for d in aug_dists)

print("=== Augmentation Validation ===")
print(f"Total augmented users:   {len(all_aug_users):,}")
print(f"Total expanded servers:  {len(expanded_servers)}")
print(f"Total server capacity:   {len(expanded_servers)*SERVER_CAPACITY:,}")
print(f"Reachability (sample):   {aug_cov}/1000 = {100*aug_cov/1000:.1f}% within 3km")
print(f"Lat range: {all_aug_users['Latitude'].min():.4f} to {all_aug_users['Latitude'].max():.4f}")
print(f"Lon range: {all_aug_users['Longitude'].min():.4f} to {all_aug_users['Longitude'].max():.4f}")
aug_lat, aug_lon, aug_area = bbox_km(all_aug_users, 'Latitude', 'Longitude')
print(f"Coverage area: {aug_lat:.1f} km x {aug_lon:.1f} km = {aug_area:.0f} km²")

# Visual comparison
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
fig.suptitle('Augmentation Validation', fontsize=13, fontweight='bold')

ax = axes[0]
ax.scatter(metro_users['Longitude'], metro_users['Latitude'], s=1, alpha=0.05, color='#888', label='Original Metro')
ax.scatter(all_aug_users['Longitude'], all_aug_users['Latitude'], s=2, alpha=0.2, color='#4C72B0', label=f'Augmented ({len(all_aug_users):,})')
ax.scatter(expanded_servers['LONGITUDE'], expanded_servers['LATITUDE'], s=30, color='black', marker='^', zorder=5, label=f'Servers ({len(expanded_servers)})')
ax.set_title('Spatial Distribution'); ax.legend(fontsize=9)

ax = axes[1]
ax.hist(aug_dists, bins=30, color='#4C72B0', edgecolor='black', alpha=0.8)
ax.axvline(MAX_COVERAGE_KM, color='red', ls='--', lw=2, label='3km threshold')
ax.set_xlabel('Distance to nearest server (km)')
ax.set_title(f'Reachability (sample)  —  {100*aug_cov/1000:.1f}% within 3km')
ax.legend()

plt.tight_layout()
plt.savefig('augmentation_validation.png', dpi=120, bbox_inches='tight')
plt.show()
=== Augmentation Validation ===
Total augmented users:   14,899
Total expanded servers:  28
Total server capacity:   1,400
Reachability (sample):   992/1000 = 99.2% within 3km
Lat range: -37.8984 to -37.7398
Lon range: 144.8723 to 145.0622
Coverage area: 17.6 km x 21.1 km = 371 km²
No description has been provided for this image

7. Greedy Algorithm¶

In [16]:
def solve_greedy(servers_input, users_input, max_cov=MAX_COVERAGE_KM, cap=SERVER_CAPACITY):
    srv = servers_input.copy().reset_index(drop=True)
    usr = users_input.copy().reset_index(drop=True)
    srv['active'] = False; srv['current_load'] = 0
    usr['assigned'] = None; usr['dist'] = np.nan

    # build coverage map: server -> list of (user_idx, distance)
    cov = {si: [] for si in range(len(srv))}
    for ui, u in usr.iterrows():
        for si, s in srv.iterrows():
            d = haversine(u['Latitude'], u['Longitude'], s['LATITUDE'], s['LONGITUDE'])
            if d <= max_cov:
                cov[si].append((ui, d))

    unassigned = set(usr.index)
    while unassigned:
        # pick server with most coverable unassigned users
        best_si = max(
            (si for si in range(len(srv)) if srv.at[si,'current_load'] < cap),
            key=lambda si: min(len([x for x in cov[si] if x[0] in unassigned]),
                               cap - srv.at[si,'current_load']),
            default=None
        )
        if best_si is None:
            break
        candidates = sorted([x for x in cov[best_si] if x[0] in unassigned], key=lambda x: x[1])
        if not candidates:
            break
        srv.at[best_si, 'active'] = True
        for ui, dist in candidates[:cap]:
            usr.at[ui, 'assigned'] = best_si
            usr.at[ui, 'dist']     = dist
            unassigned.discard(ui)
            srv.at[best_si, 'current_load'] += 1

    active   = srv[srv['active']]
    assigned = usr[usr['assigned'].notna()]
    return {
        'active_count': len(active),
        'coverage_pct': len(assigned) / len(usr) * 100,
        'avg_util':     active['current_load'].mean() / cap * 100 if len(active) else 0,
        'avg_dist':     assigned['dist'].mean() if len(assigned) else 0,
    }

print("Greedy defined.")
Greedy defined.

8. BAAP Algorithm¶

In [35]:
class BiasAwarePlacement:
    """
    Bias-Aware Adaptive Placement (BAAP).
    Three stages: Learn density  ->  Compute weights  ->  Weighted placement.
    Weight formula: w(u) = 0.1 + 0.9 * normalise(0.5*kde_corr + 0.5*suburb_w)
    kde_weight and suburb_weight each fixed at 0.5.
    """
    def __init__(self, max_cov=MAX_COVERAGE_KM, cap=SERVER_CAPACITY, bw=0.015):
        self.max_cov = max_cov
        self.cap     = cap
        self.bw      = bw
        self.kde_    = None
        self.sub_df_ = None

    def fit(self, users_df, suburb_clusters):
        self.kde_ = KernelDensity(kernel='gaussian', bandwidth=self.bw)
        self.kde_.fit(users_df[['Latitude','Longitude']].values)
        total_pop = sum(s[3] for s in suburb_clusters)
        self.sub_df_ = pd.DataFrame([
            {'lat': s[1], 'lon': s[2], 'pw': s[3]/total_pop}
            for s in suburb_clusters
        ])

    def _weights(self, usr):
        coords = usr[['Latitude','Longitude']].values
        # KDE correction: invert density (sparse -> high weight)
        ld  = np.exp(self.kde_.score_samples(coords))
        kc  = 1 / (ld + 1e-10)
        kc  = (kc - kc.min()) / (kc.max() - kc.min() + 1e-10)
        # suburb population weight
        tree = cKDTree(self.sub_df_[['lat','lon']].values)
        _, ni = tree.query(coords)
        sw = self.sub_df_['pw'].values[ni]
        sw = (sw - sw.min()) / (sw.max() - sw.min() + 1e-10)
        combined = 0.6*kc + 0.4*sw
        combined = (combined - combined.min()) / (combined.max() - combined.min() + 1e-10)
        return 0.1 + 0.9*combined

    def solve(self, servers_input, users_input):
        srv = servers_input.copy().reset_index(drop=True)
        usr = users_input.copy().reset_index(drop=True)
        srv['active'] = False; srv['current_load'] = 0
        usr['assigned'] = None; usr['dist'] = np.nan
        usr['weight'] = self._weights(usr)

        cov = {si: [] for si in range(len(srv))}
        for ui, u in usr.iterrows():
            for si, s in srv.iterrows():
                d = haversine(u['Latitude'], u['Longitude'], s['LATITUDE'], s['LONGITUDE'])
                if d <= self.max_cov:
                    cov[si].append((ui, d, u['weight']))

        unassigned = set(usr.index)
        while unassigned:
            # score = sum of weights of coverable unassigned users
            best_si, best_score = None, -1
            for si in range(len(srv)):
                if srv.at[si,'current_load'] >= self.cap:
                    continue
                cands = [(u,d,w) for u,d,w in cov[si] if u in unassigned]
                slots  = self.cap - srv.at[si,'current_load']
                score  = sum(w for _,_,w in sorted(cands, key=lambda x:x[1])[:slots])
                if score > best_score:
                    best_score, best_si = score, si
            if best_si is None or best_score == 0:
                break
            srv.at[best_si, 'active'] = True
            to_assign = sorted([(u,d,w) for u,d,w in cov[best_si] if u in unassigned],
                               key=lambda x: x[1])[:self.cap]
            for ui, dist, _ in to_assign:
                usr.at[ui,'assigned'] = best_si
                usr.at[ui,'dist']     = dist
                unassigned.discard(ui)
                srv.at[best_si,'current_load'] += 1

        active   = srv[srv['active']]
        assigned = usr[usr['assigned'].notna()]
        return {
            'active_count': len(active),
            'coverage_pct': len(assigned) / len(usr) * 100,
            'avg_util':     active['current_load'].mean() / self.cap * 100 if len(active) else 0,
            'avg_dist':     assigned['dist'].mean() if len(assigned) else 0,
        }

# Fit BAAP once on the full augmented dataset
baap = BiasAwarePlacement()
baap.fit(all_aug_users, suburb_clusters)
print("BAAP fitted.")
BAAP fitted.

9. Run Comparisons (single seed check)¶

In [36]:
LOADS = [100, 500, 1000, 2000, 5000]

def prep_users(load, seed, dataset=None):
    src = dataset if dataset is not None else all_aug_users
    u = src.sample(min(load, len(src)), random_state=seed)
    return u

# in fresh_servers(), add a flag
def fresh_servers(use_original=False):
    s = servers.copy() if use_original else expanded_servers.copy()
    s['capacity'] = SERVER_CAPACITY; s['current_load'] = 0; s['active'] = False
    return s

# Single-seed sanity check (seed=SEED)
rows = []
for load in LOADS:
    u = prep_users(load, SEED)
    g = solve_greedy(fresh_servers(), u.copy())
    b = baap.solve(fresh_servers(), u.copy())
    rows.append({'Load': load, 'Algorithm': 'Greedy', **g})
    rows.append({'Load': load, 'Algorithm': 'BAAP',   **b})

df_check = pd.DataFrame(rows)
print(df_check.to_string(index=False))
 Load Algorithm  active_count  coverage_pct   avg_util  avg_dist
  100    Greedy            11          99.0  18.000000  1.741506
  100      BAAP             9          99.0  22.000000  1.648896
  500    Greedy            14          99.4  71.000000  1.426359
  500      BAAP            15          99.4  66.266667  1.332150
 1000    Greedy            22          97.4  88.545455  1.182727
 1000      BAAP            20          95.4  95.400000  1.019862
 2000    Greedy            28          70.0 100.000000  0.767443
 2000      BAAP            28          70.0 100.000000  0.849817
 5000    Greedy            28          28.0 100.000000  0.248894
 5000      BAAP            28          28.0 100.000000  0.250204

10. Repeated Experiments (N=25 seeds)¶

In [19]:
# ── BAAP fitted on augmented data — knows the true distribution ───────────────
baap_aug = BiasAwarePlacement(bw=0.015)
baap_aug.fit(all_aug_users, suburb_clusters)

def prep_users_orig(load, seed):
    src = cbd_users if load <= 800 else metro_users
    u = src.sample(min(load, len(src)), random_state=seed)[['Latitude','Longitude']].copy().reset_index(drop=True)
    u['User_ID'] = [f'u{i}' for i in range(len(u))]
    return u

def prep_users_aug(load, seed):
    u = all_aug_users[['Latitude','Longitude']].sample(
        min(load, len(all_aug_users)), random_state=seed
    ).reset_index(drop=True).copy()
    u['User_ID'] = [f'u{i}' for i in range(len(u))]
    return u

def fresh_servers_orig():
    s = servers.copy()
    s['capacity'] = SERVER_CAPACITY; s['current_load'] = 0; s['active'] = False
    return s

N_SEEDS   = 25
SEED_LIST = list(range(N_SEEDS))
LOADS     = [100, 500, 1000, 2000, 5000]

records = []
for seed in SEED_LIST:
    for load in LOADS:

        # Scenario A: Greedy on original data — the broken baseline
        u_orig = prep_users_orig(load, seed)
        g_orig = solve_greedy(fresh_servers_orig(), u_orig.copy())
        records.append({'seed':seed,'load':load,'scenario':'Original','algorithm':'Greedy',**g_orig})

        # Scenario B: Greedy on augmented data — data fix only, no algorithm change
        u_aug = prep_users_aug(load, seed)
        g_aug = solve_greedy(fresh_servers(), u_aug.copy())
        records.append({'seed':seed,'load':load,'scenario':'Augmented','algorithm':'Greedy',**g_aug})

        # Scenario C: BAAP on augmented data — data fix + better algorithm
        # BAAP is fitted on augmented so it knows true distribution
        # even if future data drifts or is re-biased, BAAP corrects for it
        b_aug = baap_aug.solve(fresh_servers(), u_aug.copy())
        records.append({'seed':seed,'load':load,'scenario':'Augmented','algorithm':'BAAP',**b_aug})

    # live progress — only show what matters
    tmp = pd.DataFrame(records)
    g_o = tmp[(tmp['scenario']=='Original')  & (tmp['algorithm']=='Greedy') & (tmp['load']==1000)]['coverage_pct'].mean()
    g_a = tmp[(tmp['scenario']=='Augmented') & (tmp['algorithm']=='Greedy') & (tmp['load']==1000)]['coverage_pct'].mean()
    b_a = tmp[(tmp['scenario']=='Augmented') & (tmp['algorithm']=='BAAP')   & (tmp['load']==1000)]['coverage_pct'].mean()
    gd  = tmp[(tmp['scenario']=='Augmented') & (tmp['algorithm']=='Greedy') & (tmp['load']==1000)]['avg_dist'].mean()
    bd  = tmp[(tmp['scenario']=='Augmented') & (tmp['algorithm']=='BAAP')   & (tmp['load']==1000)]['avg_dist'].mean()
    print(f"  Seed {seed+1:02d} | "
          f"Greedy/Orig {g_o:.1f}%  "
          f"Greedy/Aug {g_a:.1f}%  "
          f"BAAP/Aug {b_a:.1f}%  "
          f"| dist Greedy {gd:.3f}km  BAAP {bd:.3f}km  ({(gd-bd)/gd*100:+.1f}%)")

results = pd.DataFrame(records)
print(f"\nDone. {len(results)} rows")
  Seed 01 | Greedy/Orig 12.3%  Greedy/Aug 96.5%  BAAP/Aug 93.4%  | dist Greedy 1.196km  BAAP 1.002km  (+16.2%)
  Seed 02 | Greedy/Orig 12.6%  Greedy/Aug 96.5%  BAAP/Aug 94.5%  | dist Greedy 1.144km  BAAP 1.049km  (+8.3%)
  Seed 03 | Greedy/Orig 13.1%  Greedy/Aug 96.4%  BAAP/Aug 94.4%  | dist Greedy 1.137km  BAAP 1.033km  (+9.2%)
  Seed 04 | Greedy/Orig 13.2%  Greedy/Aug 96.5%  BAAP/Aug 94.2%  | dist Greedy 1.170km  BAAP 1.045km  (+10.7%)
  Seed 05 | Greedy/Orig 12.9%  Greedy/Aug 96.3%  BAAP/Aug 94.0%  | dist Greedy 1.179km  BAAP 1.042km  (+11.6%)
  Seed 06 | Greedy/Orig 13.1%  Greedy/Aug 96.5%  BAAP/Aug 94.0%  | dist Greedy 1.181km  BAAP 1.031km  (+12.7%)
  Seed 07 | Greedy/Orig 13.2%  Greedy/Aug 96.6%  BAAP/Aug 94.0%  | dist Greedy 1.189km  BAAP 1.034km  (+13.1%)
  Seed 08 | Greedy/Orig 13.3%  Greedy/Aug 96.4%  BAAP/Aug 94.1%  | dist Greedy 1.187km  BAAP 1.042km  (+12.2%)
  Seed 09 | Greedy/Orig 13.1%  Greedy/Aug 96.5%  BAAP/Aug 93.9%  | dist Greedy 1.195km  BAAP 1.042km  (+12.8%)
  Seed 10 | Greedy/Orig 13.2%  Greedy/Aug 96.4%  BAAP/Aug 93.8%  | dist Greedy 1.187km  BAAP 1.036km  (+12.7%)
  Seed 11 | Greedy/Orig 13.1%  Greedy/Aug 96.5%  BAAP/Aug 93.7%  | dist Greedy 1.191km  BAAP 1.035km  (+13.1%)
  Seed 12 | Greedy/Orig 13.2%  Greedy/Aug 96.4%  BAAP/Aug 93.7%  | dist Greedy 1.182km  BAAP 1.037km  (+12.3%)
  Seed 13 | Greedy/Orig 13.3%  Greedy/Aug 96.5%  BAAP/Aug 93.8%  | dist Greedy 1.178km  BAAP 1.037km  (+12.0%)
  Seed 14 | Greedy/Orig 13.2%  Greedy/Aug 96.4%  BAAP/Aug 93.7%  | dist Greedy 1.175km  BAAP 1.038km  (+11.6%)
  Seed 15 | Greedy/Orig 13.2%  Greedy/Aug 96.4%  BAAP/Aug 93.7%  | dist Greedy 1.171km  BAAP 1.040km  (+11.2%)
  Seed 16 | Greedy/Orig 13.2%  Greedy/Aug 96.3%  BAAP/Aug 93.6%  | dist Greedy 1.169km  BAAP 1.043km  (+10.8%)
  Seed 17 | Greedy/Orig 13.3%  Greedy/Aug 96.2%  BAAP/Aug 93.6%  | dist Greedy 1.168km  BAAP 1.044km  (+10.7%)
  Seed 18 | Greedy/Orig 13.3%  Greedy/Aug 96.3%  BAAP/Aug 93.7%  | dist Greedy 1.171km  BAAP 1.046km  (+10.6%)
  Seed 19 | Greedy/Orig 13.2%  Greedy/Aug 96.4%  BAAP/Aug 93.6%  | dist Greedy 1.174km  BAAP 1.046km  (+10.9%)
  Seed 20 | Greedy/Orig 13.2%  Greedy/Aug 96.4%  BAAP/Aug 93.6%  | dist Greedy 1.178km  BAAP 1.046km  (+11.2%)
  Seed 21 | Greedy/Orig 13.2%  Greedy/Aug 96.4%  BAAP/Aug 93.6%  | dist Greedy 1.179km  BAAP 1.046km  (+11.3%)
  Seed 22 | Greedy/Orig 13.2%  Greedy/Aug 96.4%  BAAP/Aug 93.7%  | dist Greedy 1.183km  BAAP 1.048km  (+11.4%)
  Seed 23 | Greedy/Orig 13.3%  Greedy/Aug 96.4%  BAAP/Aug 93.7%  | dist Greedy 1.181km  BAAP 1.047km  (+11.4%)
  Seed 24 | Greedy/Orig 13.2%  Greedy/Aug 96.4%  BAAP/Aug 93.7%  | dist Greedy 1.176km  BAAP 1.047km  (+11.0%)
  Seed 25 | Greedy/Orig 13.2%  Greedy/Aug 96.3%  BAAP/Aug 93.7%  | dist Greedy 1.173km  BAAP 1.046km  (+10.8%)

Done. 375 rows

11. Results: Tables and Figures¶

In [23]:
metrics = {
    'coverage_pct': 'Coverage (%)',
    'active_count': 'Active Servers',
    'avg_util':     'Avg Utilisation (%)',
    'avg_dist':     'Avg Distance (km)',
}

def ci95(x):
    n = len(x)
    return stats.t.ppf(0.975, df=n-1) * x.std(ddof=1) / np.sqrt(n)


available = results['scenario'].unique().tolist()
n_rows = len(available)

fig, axes = plt.subplots(n_rows, 4, figsize=(20, 5*n_rows))
if n_rows == 1:
    axes = axes.reshape(1, -1)
fig.suptitle('Greedy vs BAAP — Results (N=25 seeds)', fontsize=14, fontweight='bold')
colors = {'Greedy': '#4C72B0', 'BAAP': '#C44E52'}

for row, scenario in enumerate(available):
    sub_r = results[results['scenario'] == scenario]
    for col, (metric, label) in enumerate(metrics.items()):
        ax = axes[row, col]
        for algo in ['Greedy', 'BAAP']:
            sub = sub_r[sub_r['algorithm'] == algo]
            if sub.empty:
                continue
            means = sub.groupby('load')[metric].mean()
            cis   = sub.groupby('load')[metric].apply(ci95)
            ax.plot(LOADS, means.values, marker='o', lw=2.2,
                    label=algo, color=colors[algo])
            ax.fill_between(LOADS,
                            means.values - cis.values,
                            means.values + cis.values,
                            alpha=0.18, color=colors[algo])
        ax.set_title(f'{scenario}  —  {label}', fontweight='bold')
        ax.set_xlabel('Traffic Load (users)')
        ax.set_ylabel(label)
        ax.legend()

plt.tight_layout()
plt.savefig('results_comparison.png', dpi=150, bbox_inches='tight')
plt.show()
No description has been provided for this image
In [24]:
fig, axes = plt.subplots(2, 3, figsize=(18, 11))
fig.suptitle('BAAP vs Greedy — Performance Analysis (N=25 seeds)',
             fontsize=14, fontweight='bold')

colors = {'Greedy': '#4C72B0', 'BAAP': '#C44E52'}
aug = results[results['scenario'] == 'Augmented']

# Plot 1: Coverage % — show they are equal (BAAP not worse)
ax = axes[0, 0]
for algo in ['Greedy', 'BAAP']:
    sub   = aug[aug['algorithm'] == algo]
    means = sub.groupby('load')['coverage_pct'].mean()
    cis   = sub.groupby('load')['coverage_pct'].apply(ci95)
    ax.plot(LOADS, means.values, marker='o', lw=2.2, label=algo, color=colors[algo])
    ax.fill_between(LOADS, means.values - cis.values, means.values + cis.values,
                    alpha=0.18, color=colors[algo])
ax.set_title('Coverage % — Augmented Data', fontweight='bold')
ax.set_xlabel('Traffic Load (users)'); ax.set_ylabel('Coverage (%)')
ax.legend(); ax.set_ylim(0, 105)

# Plot 2: Avg Distance — this is where BAAP wins
ax = axes[0, 1]
for algo in ['Greedy', 'BAAP']:
    sub   = aug[aug['algorithm'] == algo]
    means = sub.groupby('load')['avg_dist'].mean()
    cis   = sub.groupby('load')['avg_dist'].apply(ci95)
    ax.plot(LOADS, means.values, marker='o', lw=2.2, label=algo, color=colors[algo])
    ax.fill_between(LOADS, means.values - cis.values, means.values + cis.values,
                    alpha=0.18, color=colors[algo])
ax.set_title('Avg User-Server Distance — BAAP Lower = Less Latency', fontweight='bold')
ax.set_xlabel('Traffic Load (users)'); ax.set_ylabel('Avg Distance (km)')
ax.legend()

# Plot 3: Distance improvement % bar chart
ax = axes[0, 2]
improvements = []
for load in LOADS:
    g = aug[(aug['load']==load) & (aug['algorithm']=='Greedy')]['avg_dist'].mean()
    b = aug[(aug['load']==load) & (aug['algorithm']=='BAAP')]['avg_dist'].mean()
    improvements.append((g - b) / g * 100)
bars = ax.bar(range(len(LOADS)), improvements,
              color=['#2ecc71' if x > 0 else '#e74c3c' for x in improvements],
              edgecolor='black', linewidth=0.8)
ax.axhline(0, color='black', lw=1)
ax.set_xticks(range(len(LOADS))); ax.set_xticklabels(LOADS)
ax.set_title('BAAP Distance Improvement over Greedy (%)', fontweight='bold')
ax.set_xlabel('Traffic Load (users)'); ax.set_ylabel('Improvement (%)')
for bar, val in zip(bars, improvements):
    ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.1,
            f'{val:+.1f}%', ha='center', va='bottom', fontsize=9, fontweight='bold')

# Plot 4: Active servers — BAAP more efficient
ax = axes[1, 0]
for algo in ['Greedy', 'BAAP']:
    sub   = aug[aug['algorithm'] == algo]
    means = sub.groupby('load')['active_count'].mean()
    cis   = sub.groupby('load')['active_count'].apply(ci95)
    ax.plot(LOADS, means.values, marker='o', lw=2.2, label=algo, color=colors[algo])
    ax.fill_between(LOADS, means.values - cis.values, means.values + cis.values,
                    alpha=0.18, color=colors[algo])
ax.set_title('Active Servers — Fewer = More Energy Efficient', fontweight='bold')
ax.set_xlabel('Traffic Load (users)'); ax.set_ylabel('Active Servers')
ax.legend()

# Plot 5: Data quality effect — the headline finding
ax = axes[1, 1]
orig = results[results['scenario'] == 'Original']
scenarios_cov = {
    'Greedy\n(Original data)':  orig[orig['algorithm']=='Greedy'].groupby('load')['coverage_pct'].mean(),
    'Greedy\n(Augmented data)': aug[aug['algorithm']=='Greedy'].groupby('load')['coverage_pct'].mean(),
    'BAAP\n(Augmented data)':   aug[aug['algorithm']=='BAAP'].groupby('load')['coverage_pct'].mean(),
}
line_styles = [('--', '#888888'), ('-', '#4C72B0'), ('-', '#C44E52')]
for (label, means), (ls, col) in zip(scenarios_cov.items(), line_styles):
    ax.plot(LOADS, means.values, marker='o', lw=2.2, linestyle=ls,
            label=label, color=col)
ax.set_title('Coverage % — All Scenarios', fontweight='bold')
ax.set_xlabel('Traffic Load (users)'); ax.set_ylabel('Coverage (%)')
ax.legend(fontsize=8); ax.set_ylim(0, 105)

# Plot 6: Latency proxy — convert distance to ms
ax = axes[1, 2]
MS_PER_KM = 20 / MAX_COVERAGE_KM   # FIXED: was hardcoded to 5 (inconsistent with the
                                   # 3km coverage radius / 20ms threshold assumption).
                                   # Now derived: 20ms / 3km ≈ 6.67 ms/km.
for algo in ['Greedy', 'BAAP']:
    sub   = aug[aug['algorithm'] == algo]
    means = sub.groupby('load')['avg_dist'].mean() * MS_PER_KM
    cis   = sub.groupby('load')['avg_dist'].apply(ci95) * MS_PER_KM
    ax.plot(LOADS, means.values, marker='o', lw=2.2, label=algo, color=colors[algo])
    ax.fill_between(LOADS, means.values - cis.values, means.values + cis.values,
                    alpha=0.18, color=colors[algo])
ax.axhline(20, color='red', lw=2, ls='--', label='20ms latency threshold')
ax.set_title('Estimated Propagation Latency (ms)', fontweight='bold')
ax.set_xlabel('Traffic Load (users)'); ax.set_ylabel('Latency (ms)')
ax.legend(fontsize=9)

plt.tight_layout()
plt.savefig('baap_advantage.png', dpi=150, bbox_inches='tight')
plt.show()

# Print the key numbers
print("\n=== Key Visual Evidence for BAAP ===")
for load in [500, 1000, 2000]:
    g = aug[(aug['load']==load) & (aug['algorithm']=='Greedy')]['avg_dist'].mean()
    b = aug[(aug['load']==load) & (aug['algorithm']=='BAAP')]['avg_dist'].mean()
    imp = (g-b)/g*100
    print(f"  Load {load:>4}: Greedy {g:.3f}km ({g*MS_PER_KM:.1f}ms)  "
          f"BAAP {b:.3f}km ({b*MS_PER_KM:.1f}ms)  ({imp:+.1f}% distance reduction)")
No description has been provided for this image
=== Key Visual Evidence for BAAP ===
  Load  500: Greedy 1.410km (9.4ms)  BAAP 1.375km (9.2ms)  (+2.5% distance reduction)
  Load 1000: Greedy 1.173km (7.8ms)  BAAP 1.046km (7.0ms)  (+10.8% distance reduction)
  Load 2000: Greedy 0.829km (5.5ms)  BAAP 0.865km (5.8ms)  (-4.3% distance reduction)
In [ ]: