Task 1 – Q1: Current Risk Framework (Legacy Risk)¶
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from scipy import stats
import os
# Load data (from /content)
# =========================
DATA_DIR = "/content"
def load_csv(filename, data_dir=DATA_DIR):
"""
Loads a CSV from Colab filesystem (e.g., /content).
If missing, raises a clear error for the grader.
"""
path = os.path.join(data_dir, filename)
if not os.path.exists(path):
raise FileNotFoundError(
f"Missing file: {path}\n"
f"Please upload '{filename}' to Colab (Files pane) before running."
)
return pd.read_csv(path)
# all input files
assets = load_csv("assets.csv")
vulns = load_csv("vulnerabilities.csv")
mapping = load_csv("asset_vuln_mapping.csv")
devices = load_csv("devices.csv")
cps_models = load_csv("50_cps_models.csv")
print("assets shape:", assets.shape)
print("vulns shape:", vulns.shape)
print("mapping shape:", mapping.shape)
display(assets.head())
display(vulns.head())
display(mapping.head())
# Basic validation
expected_assets_cols = {"asset_id", "asset_type", "asset_category"}
expected_vulns_cols = {"vuln_id", "cvss_base_score", "exploit_available"}
expected_mapping_cols = {"asset_id", "vuln_id"}
assert expected_assets_cols.issubset(set(assets.columns)), f"assets missing cols: {expected_assets_cols - set(assets.columns)}"
assert expected_vulns_cols.issubset(set(vulns.columns)), f"vulns missing cols: {expected_vulns_cols - set(vulns.columns)}"
assert expected_mapping_cols.issubset(set(mapping.columns)), f"mapping missing cols: {expected_mapping_cols - set(mapping.columns)}"
# Cleaning / typing
vulns["cvss_base_score"] = pd.to_numeric(vulns["cvss_base_score"], errors="coerce")
# exploit_available -> boolean
if vulns["exploit_available"].dtype != bool:
vulns["exploit_available"] = (
vulns["exploit_available"]
.astype(str).str.strip().str.lower()
.map({"true": True, "false": False})
.fillna(False)
)
# Merge: assets -> mapping -> vulnerabilities
df = (
assets.merge(mapping, on="asset_id", how="left")
.merge(vulns, on="vuln_id", how="left")
)
print("merged df shape:", df.shape)
display(df.head())
assets shape: (10468, 3) vulns shape: (800, 3) mapping shape: (78336, 2)
| asset_id | asset_type | asset_category | |
|---|---|---|---|
| 0 | asset_0 | PC | IT |
| 1 | asset_1 | Laptop | IT |
| 2 | asset_2 | PC | IT |
| 3 | asset_3 | Infusion Pump | Medical |
| 4 | asset_4 | Laptop | IT |
| vuln_id | cvss_base_score | exploit_available | |
|---|---|---|---|
| 0 | CVE-2024-1000 | 2.8 | False |
| 1 | CVE-2024-1001 | 7.7 | False |
| 2 | CVE-2024-1002 | 3.9 | False |
| 3 | CVE-2024-1003 | 5.2 | True |
| 4 | CVE-2024-1004 | 3.3 | False |
| asset_id | vuln_id | |
|---|---|---|
| 0 | asset_0 | CVE-2024-1251 |
| 1 | asset_1 | CVE-2024-1074 |
| 2 | asset_1 | CVE-2024-1473 |
| 3 | asset_1 | CVE-2024-1513 |
| 4 | asset_1 | CVE-2024-1361 |
merged df shape: (78336, 6)
| asset_id | asset_type | asset_category | vuln_id | cvss_base_score | exploit_available | |
|---|---|---|---|---|---|---|
| 0 | asset_0 | PC | IT | CVE-2024-1251 | 5.6 | False |
| 1 | asset_1 | Laptop | IT | CVE-2024-1074 | 6.4 | False |
| 2 | asset_1 | Laptop | IT | CVE-2024-1473 | 2.5 | False |
| 3 | asset_1 | Laptop | IT | CVE-2024-1513 | 5.3 | False |
| 4 | asset_1 | Laptop | IT | CVE-2024-1361 | 3.7 | False |
Q1.1 – Calculate Legacy Risk Score¶
# Compute Legacy Risk Score
# Legacy = min(100, sum(CVSS vulnerabilities per asset))
legacy_scores = (
df.groupby("asset_id", as_index=False)["cvss_base_score"]
.sum(min_count=1) # if all NaN -> NaN
.rename(columns={"cvss_base_score": "cvss_sum"})
)
legacy_scores["cvss_sum"] = legacy_scores["cvss_sum"].fillna(0.0)
legacy_scores["legacy_risk_score"] = np.minimum(100.0, legacy_scores["cvss_sum"])
# Join metadata
legacy_scores = legacy_scores.merge(assets, on="asset_id", how="left")
display(legacy_scores.head())
# Save output CSV
legacy_scores.to_csv("asset_legacy_risk_scores.csv", index=False)
print("Saved: asset_legacy_risk_scores.csv")
| asset_id | cvss_sum | legacy_risk_score | asset_type | asset_category | |
|---|---|---|---|---|---|
| 0 | asset_0 | 5.6 | 5.6 | PC | IT |
| 1 | asset_1 | 51.1 | 51.1 | Laptop | IT |
| 2 | asset_10 | 10.0 | 10.0 | Laptop | IT |
| 3 | asset_100 | 6.6 | 6.6 | PC | IT |
| 4 | asset_1000 | 3.4 | 3.4 | Server | IT |
Saved: asset_legacy_risk_scores.csv
I calculated the risk score for each device using the hospital’s current Legacy Risk Score formula.
For each asset, I:
- Identified all vulnerabilities mapped to the device
- Summed their CVSS base scores
- Applied an upper cap of 100:
$$ \text{Legacy Risk Score}=\min\left(100,\;\sum \text{CVSS scores}\right) $$
This produced a single risk score per device.
Risk scores were computed for all 10,468 assets, and the resulting table was exported to asset_legacy_risk_scores.csv.
Q1.2 – Plot distribution & identify distribution shape¶
# Descriptive stats + saturation
scores = legacy_scores["legacy_risk_score"].to_numpy()
sat_count = int((scores == 100).sum())
sat_pct = 100 * sat_count / len(scores)
mean_ = float(np.mean(scores))
median_ = float(np.median(scores))
p90 = float(np.percentile(scores, 90))
p95 = float(np.percentile(scores, 95))
p99 = float(np.percentile(scores, 99))
skew_ = float(stats.skew(scores))
kurt_ = float(stats.kurtosis(scores, fisher=True))
print(f"Saturation @100: {sat_count} / {len(scores)} assets ({sat_pct:.2f}%)")
print("Descriptive stats:")
print(f" mean = {mean_:.3f}")
print(f" median = {median_:.3f}")
print(f" p90 = {p90:.3f}")
print(f" p95 = {p95:.3f}")
print(f" p99 = {p99:.3f}")
print(f" skewness = {skew_:.3f}")
print(f" kurtosis = {kurt_:.3f} (Fisher)")
# % above high-risk thresholds
for thr in [50, 80, 90, 100]:
pct = 100 * (scores >= thr).mean()
print(f"% assets with score >= {thr}: {pct:.2f}%")
# Plot distribution
plt.figure(figsize=(10, 5))
plt.hist(scores, bins=50)
plt.title("Legacy Risk Score Distribution (0-100)")
plt.xlabel("Legacy Risk Score")
plt.ylabel("Number of Assets")
plt.show()
# Boxplot
plt.figure(figsize=(8, 2.5))
plt.boxplot(scores, vert=False)
plt.title("Legacy Risk Score - Boxplot")
plt.xlabel("Legacy Risk Score")
plt.show()
Saturation @100: 844 / 10468 assets (8.06%) Descriptive stats: mean = 25.662 median = 11.550 p90 = 84.300 p95 = 100.000 p99 = 100.000 skewness = 1.507 kurtosis = 1.021 (Fisher) % assets with score >= 50: 17.46% % assets with score >= 80: 10.60% % assets with score >= 90: 9.32% % assets with score >= 100: 8.06%
The distribution is highly right-skewed:
- Most devices cluster at low scores (roughly 0–15).
- There is a long right tail indicating a smaller number of devices with high cumulative vulnerability burden.
- A clear spike at 100 appears due to the hard cap (saturation).
This resembles a log-normal / gamma-like heavy-tailed distribution with right-censoring at 100 (i.e., a mixture of a continuous right-skewed component plus a point mass at 100).
In this dataset, 844 out of 10,468 assets (8.06%) are saturated at 100, meaning multiple different underlying risk levels collapse into the same displayed score.
The boxplot supports this interpretation as well. It shows a relatively low median compared to the full score range, indicating that most assets are concentrated in the low-to-moderate risk region, while many high-score outliers extend toward the upper bound. This reinforces the existence of a strong right tail and highlights the concentration of extreme observations near the saturation limit.
Q1.3 – Normality Test & Is the Mean Representative?¶
# Normality tests
np.random.seed(42)
sample_size = min(5000, len(scores))
sample = np.random.choice(scores, size=sample_size, replace=False)
shapiro_stat, shapiro_p = stats.shapiro(sample)
dag_stat, dag_p = stats.normaltest(scores)
anderson_res = stats.anderson(scores, dist="norm")
print("\nNormality tests:")
print(f"Shapiro-Wilk (n={sample_size}) -> W={shapiro_stat:.4f}, p-value={shapiro_p:.3e}")
print(f"D'Agostino K^2 (n={len(scores)}) -> stat={dag_stat:.4f}, p-value={dag_p:.3e}")
print(f"Anderson-Darling -> A2={anderson_res.statistic:.4f}")
print(" Critical values:", anderson_res.critical_values)
print(" Significance levels:", anderson_res.significance_level)
# QQ plot (standardized)
z = (scores - np.mean(scores)) / np.std(scores, ddof=1)
plt.figure(figsize=(6, 6))
stats.probplot(z, dist="norm", plot=plt)
plt.title("QQ Plot (Legacy Risk Score standardized)")
plt.show()
# Mean vs robust metrics
trimmed_mean_10 = float(stats.trim_mean(scores, 0.10))
winsorized = stats.mstats.winsorize(scores, limits=[0.05, 0.05])
winsor_mean = float(np.mean(winsorized))
print("\nMean vs robust metrics:")
print(f" mean = {mean_:.3f}")
print(f" median = {median_:.3f}")
print(f" trimmed mean10 = {trimmed_mean_10:.3f}")
print(f" winsor mean5 = {winsor_mean:.3f}")
# Mean/Median lines on histogram
plt.figure(figsize=(10, 5))
plt.hist(scores, bins=50)
plt.axvline(mean_, linewidth=2, label=f"Mean={mean_:.2f}")
plt.axvline(median_, linewidth=2, label=f"Median={median_:.2f}")
plt.title("Legacy Risk Score Distribution + Mean/Median")
plt.xlabel("Legacy Risk Score")
plt.ylabel("Number of Assets")
plt.legend()
plt.show()
Normality tests: Shapiro-Wilk (n=5000) -> W=0.7345, p-value=4.624e-67 D'Agostino K^2 (n=10468) -> stat=2404.8651, p-value=0.000e+00 Anderson-Darling -> A2=1048.6330 Critical values: [0.576 0.656 0.787 0.918 1.092] Significance levels: [15. 10. 5. 2.5 1. ]
Mean vs robust metrics: mean = 25.662 median = 11.550 trimmed mean10 = 19.444 winsor mean5 = 25.692
The hospital reports “Overall Risk” as the mean of all device scores.
I evaluated normality using Shapiro-Wilk (on a sample), D’Agostino’s K², Anderson-Darling, and a QQ plot.
Results:
- All tests strongly reject normality (p-value ≈ 0)
- The QQ plot shows major deviation from the normal reference line and flattening in the upper tail due to censoring at 100
- The distribution is strongly skewed and not well summarized by a single symmetric location metric
Descriptive statistics also highlight that the mean is not representative of a “typical” device:
- Mean: 25.66
- Median: 11.55
- P95 = 100, P99 = 100
Conclusion:
I do not believe the mean alone is an ideal metric for communicating the hospital’s risk posture to the CISO, because it is influenced by tail behavior and saturation, and it hides how risk is concentrated in the riskiest segment.
Suggested alternatives for reporting overall risk:
- Median + high percentiles (P90/P95)
- % of assets above critical thresholds (e.g., score ≥ 80, score = 100)
- A dedicated “tail-risk” KPI focusing on the riskiest devices that drive remediation priorities
Q2: New Risk Formula¶
1. Proposed Risk Formula¶
In order to address the saturation issue observed in the legacy risk model, I proposed a new risk formula that preserves the overall idea of aggregating vulnerability severity, while avoiding a hard cutoff at the maximum score. Instead of capping the risk score at 100, the new formula applies a smooth, bounded transformation to the cumulative CVSS score.
Let $S$ denote the sum of CVSS base scores for all vulnerabilities mapped to a device. The new risk score is defined as: $$ \text{New Risk Score} = 100 \cdot \left(1 - e^{-\frac{S}{s}}\right) $$ where $s$ is a scale parameter that controls how quickly the score approaches 100.
This function is bounded between 0 and 100, increases monotonically with the cumulative CVSS severity, and gradually saturates instead of abruptly flattening. As a result, devices with higher underlying risk continue to receive higher scores, even when their cumulative CVSS values are very large.
To avoid choosing the scale parameter arbitrarily, $s$ was calibrated using the empirical distribution of $S$. Specifically, $s$ was chosen such that devices with $S$ at the 90th percentile receive a risk score of approximately 80. This keeps the majority of assets well spread across the score range, while allowing only the most extreme cases to approach the upper bound.
Conclusion:
This formulation maintains interpretability, remains within the required 0–100 range, and resolves the saturation problem present in the legacy risk model.
import numpy as np
# Calibrate scale 's' so that score at p90(cvss_sum) is ~80
p90_sum = np.percentile(legacy_scores["cvss_sum"], 90)
s = p90_sum / (-np.log(0.2)) # solves: 80 = 100*(1-exp(-p90/s))
print(f"p90(cvss_sum)={p90_sum:.3f} -> scale s={s:.3f}")
# New score: smooth bounded transform of cvss_sum
legacy_scores["new_risk_score"] = 100 * (1 - np.exp(-legacy_scores["cvss_sum"] / s))
legacy_scores["new_risk_score"] = legacy_scores["new_risk_score"].clip(0, 100).round(3)
# Summary stats
new_scores = legacy_scores["new_risk_score"].to_numpy()
legacy_scores_arr = legacy_scores["legacy_risk_score"].to_numpy()
print("\nNew score stats:")
print(
f"min={new_scores.min():.3f}, "
f"median={np.median(new_scores):.3f}, "
f"mean={new_scores.mean():.3f}, "
f"max={new_scores.max():.3f}"
)
print(
f"p90={np.percentile(new_scores,90):.3f}, "
f"p95={np.percentile(new_scores,95):.3f}, "
f"p99={np.percentile(new_scores,99):.3f}"
)
# Compare saturation (legacy hits 100 exactly; new approaches 100 asymptotically)
near_max_thr = 99.5
legacy_max_count = int((legacy_scores_arr == 100).sum())
near_max_count = int((new_scores >= near_max_thr).sum())
print("\nSaturation:")
print(f"Legacy == 100: {legacy_max_count}/{len(legacy_scores_arr)} ({100*legacy_max_count/len(legacy_scores_arr):.2f}%)")
print(f"New >= {near_max_thr}: {near_max_count}/{len(new_scores)} ({100*near_max_count/len(new_scores):.2f}%)")
display(
legacy_scores[["asset_id","cvss_sum","legacy_risk_score","new_risk_score","asset_type","asset_category"]].head()
)
p90(cvss_sum)=84.300 -> scale s=52.379 New score stats: min=1.139, median=19.789, mean=31.586, max=99.995 p90=80.000, p95=93.755, p99=99.982 Saturation: Legacy == 100: 844/10468 (8.06%) New >= 99.5: 227/10468 (2.17%)
| asset_id | cvss_sum | legacy_risk_score | new_risk_score | asset_type | asset_category | |
|---|---|---|---|---|---|---|
| 0 | asset_0 | 5.6 | 5.6 | 10.140 | PC | IT |
| 1 | asset_1 | 51.1 | 51.1 | 62.303 | Laptop | IT |
| 2 | asset_10 | 10.0 | 10.0 | 17.380 | Laptop | IT |
| 3 | asset_100 | 6.6 | 6.6 | 11.839 | PC | IT |
| 4 | asset_1000 | 3.4 | 3.4 | 6.285 | Server | IT |
After applying the new formula, I verified that the calibration behaved as intended.
With $s=52.379$ based on $p90(S)=84.300$, the 90th percentile of the new risk score is exactly 80, matching the design goal.
Compared to the legacy model, the new score remains informative in the upper tail. High-risk assets are no longer collapsed into a single value, and the distribution preserves separation among severe cases (e.g., $p95=93.755$, $p99=99.982$).
This is also reflected in saturation behavior: while 8.06% of assets receive the maximum score under the legacy model, only 2.17% of assets reach near-maximum values ($\geq 99.5$) under the new formula.
2. How many assets reach the maximum risk score?¶
To quantify the saturation problem, I compared how many assets receive the maximum risk score (100) under the Legacy formula versus the New formula.
In the legacy method, many devices are clipped to 100 due to the hard cap, which creates a large “spike” at the maximum value and prevents meaningful prioritization among the highest-risk assets.
In the new method, the score is bounded between 0 and 100 but approaches 100 smoothly rather than abruptly. Therefore, far fewer assets should reach exactly 100, and high-risk assets remain better separated.
I computed, for each method:
- the number of assets with a score at the maximum level (100)
- the corresponding percentage out of all assets
This provides a clear before/after comparison of saturation in the two scoring approaches.
import numpy as np
import matplotlib.pyplot as plt
# Total number of assets
n_assets = len(legacy_scores)
# Define "max" condition
# Legacy score is exactly 100
legacy_max_mask = legacy_scores["legacy_risk_score"] >= 100
# New score is a float; due to rounding/numerical issues, treat values extremely close to 100 as "max"
tol = 1e-6
new_max_mask = legacy_scores["new_risk_score"] >= (100 - tol)
# Counts and percentages
legacy_max_count = int(legacy_max_mask.sum())
new_max_count = int(new_max_mask.sum())
legacy_max_pct = 100 * legacy_max_count / n_assets
new_max_pct = 100 * new_max_count / n_assets
print("=== Saturation / Maximum Score Comparison ===")
print(f"Total assets: {n_assets}")
print(f"Legacy: max score assets = {legacy_max_count} ({legacy_max_pct:.2f}%)")
print(f"New: max score assets = {new_max_count} ({new_max_pct:.2f}%)")
# compare the upper tail more clearly
# How many are very close to max (e.g., >= 95)?
legacy_95 = int((legacy_scores["legacy_risk_score"] >= 95).sum())
new_95 = int((legacy_scores["new_risk_score"] >= 95).sum())
print("\n=== Upper Tail (>=95) Comparison ===")
print(f"Legacy: assets >=95 = {legacy_95} ({100*legacy_95/n_assets:.2f}%)")
print(f"New: assets >=95 = {new_95} ({100*new_95/n_assets:.2f}%)")
# Plot legacy distribution
plt.figure(figsize=(10, 4))
plt.hist(legacy_scores["legacy_risk_score"], bins=50)
plt.title("Legacy Risk Score Distribution (0–100)")
plt.xlabel("Legacy Risk Score")
plt.ylabel("Number of Assets")
plt.show()
# Plot new distribution
plt.figure(figsize=(10, 4))
plt.hist(legacy_scores["new_risk_score"], bins=50)
plt.title("New Risk Score Distribution (0–100)")
plt.xlabel("New Risk Score")
plt.ylabel("Number of Assets")
plt.show()
=== Saturation / Maximum Score Comparison === Total assets: 10468 Legacy: max score assets = 844 (8.06%) New: max score assets = 0 (0.00%) === Upper Tail (>=95) Comparison === Legacy: assets >=95 = 909 (8.68%) New: assets >=95 = 473 (4.52%)
The results clearly highlight the difference between the two approaches.
Using the legacy risk score, 844 out of 10,468 assets (8.06%) receive the maximum score of 100, confirming the saturation problem caused by the hard cap.
Under the new risk formula, no assets reach the exact value of 100. This is expected, since the score approaches the upper bound smoothly rather than being clipped. As a result, high-risk assets remain distinguishable instead of collapsing into a single maximum value.
I also compared assets with scores ≥ 95. While 8.68% of assets fall into this range under the legacy method, this drops to 4.52% under the new method. This reduction shows that the new score distributes risk more gradually in the upper tail and provides improved resolution among the most critical devices.
Q3: Top 10 Riskiest Devices — Legacy vs. New Ranking¶
Here, I identify the Top 10 riskiest devices using both the Legacy Risk Score and the New Risk Score, and demonstrate why the proposed ranking is more useful in practice.
First, I compare the top-ranked devices produced by each scoring method.
Since the legacy formula applies a hard cap at 100, many assets share the same maximum score, making the top ranking ambiguous. Therefore, I explicitly avoid using additional risk-related tie-breakers when ranking by the legacy score, in order to reflect how the legacy model behaves in practice.
Next, I rank devices using the new score, which is smooth and non-saturating, and therefore provides a meaningful ordering even among the highest-risk assets.
Finally, I focus on the most operationally relevant case: re-ranking devices within the saturated legacy group (Legacy = 100) using the new score. This highlights the practical value of the new formula for prioritization.
The code below computes all three rankings and provides visual evidence of the difference between the two scoring approaches.
import numpy as np
import pandas as pd
# sanity check
required_cols = {"asset_id", "cvss_sum", "legacy_risk_score", "new_risk_score", "asset_type", "asset_category"}
missing = required_cols - set(legacy_scores.columns)
assert len(missing) == 0, f"legacy_scores is missing required columns: {missing}"
# (A) Top 10 by Legacy (score only)
top10_legacy = (
legacy_scores
.sort_values(by=["legacy_risk_score", "asset_id"], ascending=[False, True]) # stable, but does not add extra info
.head(10)
.loc[:, ["asset_id", "asset_type", "asset_category", "cvss_sum", "legacy_risk_score", "new_risk_score"]]
.reset_index(drop=True)
)
# Top 10 by New
# New score provides ordering in the upper tail (equivalent to ordering by cvss_sum).
top10_new = (
legacy_scores
.sort_values(by=["new_risk_score", "cvss_sum", "asset_id"], ascending=[False, False, True])
.head(10)
.loc[:, ["asset_id", "asset_type", "asset_category", "cvss_sum", "legacy_risk_score", "new_risk_score"]]
.reset_index(drop=True)
)
print("=== Top 10 by Legacy Risk Score (score only) ===")
display(top10_legacy)
print("\n=== Top 10 by New Risk Score ===")
display(top10_new)
# Re-rank within the saturated legacy group (Legacy == 100)
sat = legacy_scores[legacy_scores["legacy_risk_score"] >= 100].copy()
top10_within_legacy100_by_new = (
sat.sort_values(by=["new_risk_score", "cvss_sum", "asset_id"], ascending=[False, False, True])
.head(10)
.loc[:, ["asset_id", "asset_type", "asset_category", "cvss_sum", "legacy_risk_score", "new_risk_score"]]
.reset_index(drop=True)
)
print(f"\nLegacy saturation group size (Legacy==100): {len(sat)} assets")
print("\n=== Top 10 within Legacy==100, ranked by New Risk Score ===")
display(top10_within_legacy100_by_new)
# overlap summary
legacy_set = set(top10_legacy["asset_id"])
new_set = set(top10_new["asset_id"])
overlap = sorted(list(legacy_set & new_set))
print("\n=== Overlap between Top 10 lists ===")
print(f"Overlap count: {len(overlap)} / 10")
print("Overlapping asset_ids:", overlap if overlap else "None")
# xport
top10_legacy.to_csv("top10_legacy_riskiest.csv", index=False)
top10_new.to_csv("top10_new_riskiest.csv", index=False)
top10_within_legacy100_by_new.to_csv("top10_within_legacy100_by_new.csv", index=False)
print("\nSaved:")
print(" - top10_legacy_riskiest.csv")
print(" - top10_new_riskiest.csv")
print(" - top10_within_legacy100_by_new.csv")
# Visual proof: score vs CVSS sum (Saturation vs Smooth ranking)
fig, axes = plt.subplots(1, 2, figsize=(14, 5), sharex=True)
# Legacy scatter
axes[0].scatter(
legacy_scores["cvss_sum"],
legacy_scores["legacy_risk_score"],
alpha=0.25,
s=10
)
axes[0].set_title("Legacy Risk Score vs CVSS Sum")
axes[0].set_xlabel("Cumulative CVSS Score")
axes[0].set_ylabel("Legacy Risk Score")
axes[0].set_ylim(0, 105)
axes[0].axhline(100, color="red", linestyle="--", alpha=0.7)
axes[0].text(legacy_scores["cvss_sum"].max()*0.55, 101, "Hard cap / saturation at 100", color="red")
# New scatter
axes[1].scatter(
legacy_scores["cvss_sum"],
legacy_scores["new_risk_score"],
alpha=0.25,
s=10
)
axes[1].set_title("New Risk Score vs CVSS Sum")
axes[1].set_xlabel("Cumulative CVSS Score")
axes[1].set_ylabel("New Risk Score")
axes[1].set_ylim(0, 105)
plt.tight_layout()
plt.show()
=== Top 10 by Legacy Risk Score (score only) ===
| asset_id | asset_type | asset_category | cvss_sum | legacy_risk_score | new_risk_score | |
|---|---|---|---|---|---|---|
| 0 | asset_10000 | Laptop | IT | 132.5 | 100.0 | 92.031 |
| 1 | asset_10005 | Laptop | IT | 102.1 | 100.0 | 85.762 |
| 2 | asset_10028 | PC | IT | 341.2 | 100.0 | 99.852 |
| 3 | asset_10036 | PC | IT | 509.6 | 100.0 | 99.994 |
| 4 | asset_10045 | Laptop | IT | 178.7 | 100.0 | 96.702 |
| 5 | asset_10056 | PC | IT | 133.0 | 100.0 | 92.107 |
| 6 | asset_10058 | ECG | Medical | 183.1 | 100.0 | 96.967 |
| 7 | asset_10063 | Server | IT | 108.0 | 100.0 | 87.279 |
| 8 | asset_10073 | Server | IT | 124.5 | 100.0 | 90.716 |
| 9 | asset_10075 | Server | IT | 364.3 | 100.0 | 99.905 |
=== Top 10 by New Risk Score ===
| asset_id | asset_type | asset_category | cvss_sum | legacy_risk_score | new_risk_score | |
|---|---|---|---|---|---|---|
| 0 | asset_5818 | PC | IT | 522.0 | 100.0 | 99.995 |
| 1 | asset_7817 | PC | IT | 518.2 | 100.0 | 99.995 |
| 2 | asset_7993 | PC | IT | 509.9 | 100.0 | 99.994 |
| 3 | asset_10036 | PC | IT | 509.6 | 100.0 | 99.994 |
| 4 | asset_3138 | Server | IT | 509.0 | 100.0 | 99.994 |
| 5 | asset_5850 | PC | IT | 506.2 | 100.0 | 99.994 |
| 6 | asset_3444 | PC | IT | 506.1 | 100.0 | 99.994 |
| 7 | asset_9947 | PC | IT | 503.2 | 100.0 | 99.993 |
| 8 | asset_9166 | ECG | Medical | 501.9 | 100.0 | 99.993 |
| 9 | asset_10344 | MRI Scanner | Medical | 501.7 | 100.0 | 99.993 |
Legacy saturation group size (Legacy==100): 844 assets === Top 10 within Legacy==100, ranked by New Risk Score ===
| asset_id | asset_type | asset_category | cvss_sum | legacy_risk_score | new_risk_score | |
|---|---|---|---|---|---|---|
| 0 | asset_5818 | PC | IT | 522.0 | 100.0 | 99.995 |
| 1 | asset_7817 | PC | IT | 518.2 | 100.0 | 99.995 |
| 2 | asset_7993 | PC | IT | 509.9 | 100.0 | 99.994 |
| 3 | asset_10036 | PC | IT | 509.6 | 100.0 | 99.994 |
| 4 | asset_3138 | Server | IT | 509.0 | 100.0 | 99.994 |
| 5 | asset_5850 | PC | IT | 506.2 | 100.0 | 99.994 |
| 6 | asset_3444 | PC | IT | 506.1 | 100.0 | 99.994 |
| 7 | asset_9947 | PC | IT | 503.2 | 100.0 | 99.993 |
| 8 | asset_9166 | ECG | Medical | 501.9 | 100.0 | 99.993 |
| 9 | asset_10344 | MRI Scanner | Medical | 501.7 | 100.0 | 99.993 |
=== Overlap between Top 10 lists === Overlap count: 1 / 10 Overlapping asset_ids: ['asset_10036'] Saved: - top10_legacy_riskiest.csv - top10_new_riskiest.csv - top10_within_legacy100_by_new.csv
Analysis and Interpretation¶
Using the legacy risk score, the Top 10 devices are selected from a large saturated group of 844 assets that all receive the maximum score of 100. Since all of these assets share the same value, there is no meaningful way to distinguish which devices are actually riskier. In practice, the internal ordering of the Top 10 is arbitrary and depends on technical details such as asset ID, rather than on real differences in risk.
In contrast, the new risk score continues to differentiate devices even at very high cumulative CVSS levels. As a result, the Top 10 devices ranked by the new score correspond to assets with the highest underlying vulnerability severity, and their ordering reflects real differences in risk.
This is also visible in the scatter plots: the legacy score shows a hard saturation at 100, creating a flat line where additional CVSS severity is no longer reflected. The new score increases smoothly and preserves separation between high-risk devices.
A technician with limited time needs a reliable prioritization of which devices to handle first. The legacy ranking provides no guidance within the most critical group, while the new ranking enables clear, data-driven decision making.
Q3 – Simulation (Task 1): Residual Risk After Remediation¶
Now, I simulate the Residual Risk, meaning the level of risk that remains after the CISO’s approved remediation plan is applied across the environment.
For IT assets, only high priority vulnerabilities are patched. A vulnerability is considered high-priority if:
$$ \text{CVSS} \ge 9.0 \quad \text{or} \quad \text{exploit_available} = \text{True} $$
Once patched, its severity is set to zero.
For Medical assets, patching is not operationally feasible. Instead, compensating network controls are assumed to reduce the severity of all attached vulnerabilities by 50%.
After applying these rules, I aggregate the remaining severity per asset to obtain the residual cumulative score $S_{\text{after}}$, and compute the updated risk using the same transformation defined earlier:
$$ \text{NewRisk}(S) = 100 \cdot \left(1 - e^{-S/s}\right) $$
where $S$ represents the total CVSS score per asset (before or after remediation), and $s$ is the scale parameter calibrated in Q2.
This way, I obtain a clear before-and-after comparison for each asset, which allows me to directly evaluate how the remediation strategy affects the overall risk level.
import numpy as np
import pandas as pd
# helper:
def load_from_uploaded(uploaded_dict, filename):
"""
If you're using Google Colab "files.upload()", pass the 'uploaded' dict here.
Otherwise, you can ignore this and load with pd.read_csv directly.
"""
import io
return pd.read_csv(io.BytesIO(uploaded_dict[filename]))
# Helpers:
def ensure_bool(series: pd.Series) -> pd.Series:
if series.dtype == bool:
return series
return (
series.astype(str).str.strip().str.lower()
.map({"true": True, "false": False})
.fillna(False)
.astype(bool)
)
def new_risk_score(cvss_sum: pd.Series, s: float) -> pd.Series:
return 100 * (1 - np.exp(-cvss_sum / s))
need_rebuild = any(name not in globals() for name in ["df", "legacy_scores", "s"])
if need_rebuild:
# If running locally / notebook with files in working dir:
assets = pd.read_csv("assets.csv")
vulns = pd.read_csv("vulnerabilities.csv")
mapping = pd.read_csv("asset_vuln_mapping.csv")
# Clean / normalize
vulns["cvss_base_score"] = pd.to_numeric(vulns["cvss_base_score"], errors="coerce").fillna(0.0)
vulns["exploit_available"] = ensure_bool(vulns["exploit_available"])
df = (
assets.merge(mapping, on="asset_id", how="left")
.merge(vulns, on="vuln_id", how="left")
)
df["cvss_base_score"] = pd.to_numeric(df["cvss_base_score"], errors="coerce").fillna(0.0)
df["exploit_available"] = ensure_bool(df["exploit_available"])
df["asset_category"] = df["asset_category"].astype(str).str.strip()
# Before (legacy + new risk) at asset level
legacy_scores = (
df.groupby("asset_id", as_index=False)["cvss_base_score"]
.sum()
.rename(columns={"cvss_base_score": "cvss_sum"})
.merge(assets, on="asset_id", how="left")
)
legacy_scores["cvss_sum"] = legacy_scores["cvss_sum"].fillna(0.0)
legacy_scores["legacy_risk_score"] = np.minimum(100.0, legacy_scores["cvss_sum"])
# Calibrate s: set p90(cvss_sum) -> NewRisk ≈ 80
p90_sum = np.percentile(legacy_scores["cvss_sum"], 90)
s = p90_sum / (-np.log(0.2))
legacy_scores["new_risk_score"] = new_risk_score(legacy_scores["cvss_sum"], s).clip(0, 100)
# IT: patch only high priority (CVSS>=9 OR exploit_available=True) -> 0
# Medical: cannot patch -> reduce ALL vulnerabilities by 50%
df_q3 = df.copy()
df_q3["asset_category"] = df_q3["asset_category"].astype(str).str.strip()
is_it = df_q3["asset_category"].str.lower().eq("it")
is_med = df_q3["asset_category"].str.lower().eq("medical")
high_priority = (df_q3["cvss_base_score"] >= 9.0) | (df_q3["exploit_available"] == True)
df_q3["cvss_after"] = df_q3["cvss_base_score"].astype(float)
# IT: patch high-priority vulns => 0
df_q3.loc[is_it & high_priority, "cvss_after"] = 0.0
# Medical: reduce all vulns by 50%
df_q3.loc[is_med, "cvss_after"] = 0.5 * df_q3.loc[is_med, "cvss_base_score"]
# Aggregate AFTER at asset level + merge before/after
residual_cvss = (
df_q3.groupby("asset_id", as_index=False)["cvss_after"]
.sum()
.rename(columns={"cvss_after": "cvss_sum_after"})
)
residual_cvss["cvss_sum_after"] = residual_cvss["cvss_sum_after"].fillna(0.0)
residual_cvss["new_risk_score_after"] = new_risk_score(residual_cvss["cvss_sum_after"], s).clip(0, 100)
risk_before_after = legacy_scores.merge(residual_cvss, on="asset_id", how="left")
risk_before_after["cvss_sum_after"] = risk_before_after["cvss_sum_after"].fillna(0.0)
risk_before_after["new_risk_score_after"] = risk_before_after["new_risk_score_after"].fillna(
new_risk_score(risk_before_after["cvss_sum_after"], s).clip(0, 100)
)
risk_before_after["delta_new_score"] = risk_before_after["new_risk_score_after"] - risk_before_after["new_risk_score"]
risk_before_after["delta_cvss"] = risk_before_after["cvss_sum_after"] - risk_before_after["cvss_sum"]
# Rounding for presentation
for c in ["cvss_sum", "cvss_sum_after", "new_risk_score", "new_risk_score_after", "delta_new_score", "delta_cvss"]:
risk_before_after[c] = risk_before_after[c].astype(float).round(3)
# Illustrative table: examples
top10_residual = (
risk_before_after.sort_values("new_risk_score_after", ascending=False)
.head(10)[
["asset_id","asset_type","asset_category",
"cvss_sum","cvss_sum_after",
"new_risk_score","new_risk_score_after","delta_new_score"]
]
)
top10_improvements = (
risk_before_after.sort_values("delta_new_score", ascending=True)
.head(10)[
["asset_id","asset_type","asset_category",
"cvss_sum","cvss_sum_after",
"new_risk_score","new_risk_score_after","delta_new_score"]
]
)
print("\n=== Top 10 Residual Risk (AFTER remediation) [illustrative] ===")
display(top10_residual)
print("\n=== Top 10 Improvements (largest drops) [illustrative] ===")
display(top10_improvements)
# Core numeric summaries (before vs after)
mean_before = float(risk_before_after["new_risk_score"].mean())
mean_after = float(risk_before_after["new_risk_score_after"].mean())
median_before = float(risk_before_after["new_risk_score"].median())
median_after = float(risk_before_after["new_risk_score_after"].median())
print("\n=== Overall Summary (New Risk Score) ===")
print(f"Mean before: {mean_before:.3f} | after: {mean_after:.3f} | Δ: {mean_after-mean_before:.3f}")
print(f"Median before: {median_before:.3f} | after: {median_after:.3f} | Δ: {median_after-median_before:.3f}")
print("\n=== Overall Summary (CVSS Sum) ===")
print(f"Mean CVSS before: {risk_before_after['cvss_sum'].mean():.3f}")
print(f"Mean CVSS after : {risk_before_after['cvss_sum_after'].mean():.3f}")
print(f"Mean ΔCVSS : {risk_before_after['delta_cvss'].mean():.3f}")
print(f"Median ΔCVSS : {risk_before_after['delta_cvss'].median():.3f}")
pct_improved = 100 * (risk_before_after["delta_cvss"] < 0).mean()
print(f"\nAssets improved (by CVSS decrease): {pct_improved:.2f}%")
# Save full before/after table
risk_before_after.to_csv("asset_risk_before_after_q3.csv", index=False)
print("\nSaved: asset_risk_before_after_q3.csv")
=== Top 10 Residual Risk (AFTER remediation) [illustrative] ===
| asset_id | asset_type | asset_category | cvss_sum | cvss_sum_after | new_risk_score | new_risk_score_after | delta_new_score | |
|---|---|---|---|---|---|---|---|---|
| 8239 | asset_7993 | PC | IT | 509.9 | 422.4 | 99.994 | 99.969 | -0.025 |
| 2603 | asset_292 | Laptop | IT | 495.3 | 415.7 | 99.992 | 99.964 | -0.028 |
| 8342 | asset_8085 | Server | IT | 464.2 | 411.9 | 99.986 | 99.962 | -0.024 |
| 9456 | asset_9088 | Laptop | IT | 453.6 | 410.5 | 99.983 | 99.961 | -0.022 |
| 1133 | asset_1597 | PC | IT | 498.3 | 410.4 | 99.993 | 99.960 | -0.033 |
| 5017 | asset_5092 | Laptop | IT | 485.9 | 407.9 | 99.991 | 99.959 | -0.032 |
| 624 | asset_1138 | Laptop | IT | 471.9 | 407.1 | 99.988 | 99.958 | -0.030 |
| 4236 | asset_439 | Server | IT | 462.1 | 403.9 | 99.985 | 99.955 | -0.030 |
| 2077 | asset_2446 | Laptop | IT | 468.2 | 404.2 | 99.987 | 99.955 | -0.032 |
| 9674 | asset_9284 | Server | IT | 490.4 | 402.2 | 99.991 | 99.954 | -0.037 |
=== Top 10 Improvements (largest drops) [illustrative] ===
| asset_id | asset_type | asset_category | cvss_sum | cvss_sum_after | new_risk_score | new_risk_score_after | delta_new_score | |
|---|---|---|---|---|---|---|---|---|
| 7119 | asset_6985 | Server | IT | 44.1 | 5.6 | 56.913 | 10.140 | -46.773 |
| 480 | asset_10429 | PC | IT | 42.5 | 10.5 | 55.576 | 18.165 | -37.411 |
| 6317 | asset_6262 | PC | IT | 36.9 | 7.9 | 50.564 | 14.000 | -36.564 |
| 2490 | asset_2818 | PC | IT | 29.4 | 4.2 | 42.953 | 7.705 | -35.248 |
| 2206 | asset_2562 | Server | IT | 22.1 | 0.0 | 34.422 | 0.000 | -34.422 |
| 2545 | asset_2868 | PC | IT | 39.4 | 11.0 | 52.868 | 18.942 | -33.926 |
| 3615 | asset_3830 | PC | IT | 20.3 | 0.0 | 32.129 | 0.000 | -32.129 |
| 3572 | asset_3792 | Laptop | IT | 19.8 | 0.0 | 31.478 | 0.000 | -31.478 |
| 3803 | asset_40 | Laptop | IT | 35.1 | 10.4 | 48.835 | 18.009 | -30.826 |
| 5002 | asset_5079 | Laptop | IT | 34.3 | 10.0 | 48.048 | 17.380 | -30.668 |
=== Overall Summary (New Risk Score) === Mean before: 31.586 | after: 25.830 | Δ: -5.757 Median before: 19.789 | after: 14.328 | Δ: -5.461 === Overall Summary (CVSS Sum) === Mean CVSS before: 35.645 Mean CVSS after : 25.609 Mean ΔCVSS : -10.036 Median ΔCVSS : -2.600 Assets improved (by CVSS decrease): 58.31% Saved: asset_risk_before_after_q3.csv
Results¶
After applying the remediation rules, I calculated the residual CVSS and the updated New Risk Score for the entire environment (10,468 assets).
The full before/after results were exported to:
asset_risk_before_after_q3.csv
The Top-10 tables shown above are provided for illustration only (highest residual risk and largest improvements), while all summary statistics are computed over the full dataset.
Overall, the remediation policy affects 58.31% of the assets. This follows directly from the mitigation logic: all Medical assets are impacted due to the 50% severity reduction, while only IT assets with at least one high-priority vulnerability (CVSS ≥ 9 or exploit available) are patched.
At the population level, the results show a clear reduction in risk:
- The mean New Risk Score decreases from 31.586 to 25.830 (Δ = −5.757).
- The median New Risk Score decreases from 19.789 to 14.328 (Δ = −5.461).
- The mean cumulative CVSS per asset decreases by approximately 10 points (35.645 → 25.609).
This indicates a meaningful reduction in overall technical exposure across the environment.
The assets with the highest residual risk remain very close to 100 even after remediation. This does not indicate failure of the policy; rather, these assets start with extremely large cumulative CVSS totals (in the hundreds), so even after removing high-priority vulnerabilities, their total exposure remains very high. Because the New Risk Score is based on a saturating exponential transformation, it becomes less sensitive in the extreme upper range, meaning large CVSS reductions may translate into relatively small visible score changes for already critical assets.
In contrast, the largest improvements are observed among mid-risk IT assets. For these assets, patching a limited number of high-priority vulnerabilities significantly reduces cumulative CVSS, and the scoring function is still sensitive enough in this range to reflect substantial drops in risk.
Overall, Task 1 demonstrates that the remediation strategy produces a measurable and meaningful reduction in risk across a large portion of the asset population, while the most critical assets remain high-risk and may require broader mitigation measures.
Sanity Check: Validation of Remediation Coverage¶
import pandas as pd
from IPython.display import display, HTML
cat = df_q3["asset_category"].astype(str).str.strip().str.lower()
is_it = cat.eq("it")
is_med = cat.eq("medical")
high_priority = (df_q3["cvss_base_score"] >= 9.0) | (df_q3["exploit_available"] == True)
# IT: asset is addressable if it has at least 1 high priority vulnerability
it_addressable_assets = (
df_q3.loc[is_it, ["asset_id"]]
.assign(high_priority=high_priority.loc[is_it].values)
.groupby("asset_id")["high_priority"]
.any()
)
total_assets = int(df_q3["asset_id"].nunique())
it_assets = int(df_q3.loc[is_it, "asset_id"].nunique())
med_assets = int(df_q3.loc[is_med, "asset_id"].nunique())
it_addressable = int(it_addressable_assets.sum())
total_addressable = it_addressable + med_assets
pct_it = 100 * it_addressable / it_assets
pct_med = 100.0
pct_total = 100 * total_addressable / total_assets
# HTML "flow" card
html = f"""
<div style="font-family: Arial; max-width: 900px; border:1px solid #e5e7eb; border-radius:14px; padding:16px;">
<div style="font-size:18px; font-weight:700; margin-bottom:8px;">Sanity Check — Where Remediation Applies</div>
<div style="color:#6b7280; margin-bottom:14px;">
Here I check which assets are actually affected by the remediation rules.
</div>
<div style="display:flex; gap:14px; flex-wrap:wrap;">
<div style="flex:1; min-width:240px; background:#f9fafb; border:1px solid #e5e7eb; border-radius:12px; padding:12px;">
<div style="font-weight:700; margin-bottom:6px;">Total Environment</div>
<div style="font-size:28px; font-weight:800;">{total_assets:,}</div>
<div style="color:#6b7280;">assets in total</div>
</div>
<div style="flex:1; min-width:240px; background:#f9fafb; border:1px solid #e5e7eb; border-radius:12px; padding:12px;">
<div style="font-weight:700; margin-bottom:6px;">Medical Assets</div>
<div style="font-size:28px; font-weight:800;">{med_assets:,}</div>
<div style="color:#6b7280;">all of them are affected (50% reduction applies)</div>
</div>
<div style="flex:1; min-width:240px; background:#f9fafb; border:1px solid #e5e7eb; border-radius:12px; padding:12px;">
<div style="font-weight:700; margin-bottom:6px;">IT Assets</div>
<div style="font-size:28px; font-weight:800;">{it_assets:,}</div>
<div style="color:#6b7280;">{pct_it:.2f}% have at least one high-priority vulnerability</div>
</div>
</div>
<div style="margin:16px 0 10px; display:flex; align-items:center; gap:10px;">
<div style="flex:1; height:2px; background:#e5e7eb;"></div>
<div style="color:#6b7280; font-weight:700;">⇒ Overall impact</div>
<div style="flex:1; height:2px; background:#e5e7eb;"></div>
</div>
<div style="background:#eef2ff; border:1px solid #c7d2fe; border-radius:12px; padding:12px;">
<div style="font-weight:800; font-size:16px;">Assets affected by the policy</div>
<div style="display:flex; gap:14px; flex-wrap:wrap; align-items:baseline; margin-top:6px;">
<div style="font-size:28px; font-weight:900;">{pct_total:.2f}%</div>
<div style="color:#6b7280;">({total_addressable:,} out of {total_assets:,} assets)</div>
</div>
</div>
<div style="color:#6b7280; margin-top:12px; font-size:13px;">
In practice, this means that all Medical devices are impacted by the mitigation,
while only IT devices that contain at least one high-priority vulnerability are actually changed.
This explains why we observe improvement in about {pct_total:.2f}% of the assets in Task 1.
</div>
</div>
"""
display(HTML(html))
import numpy as np
print("=== Output sanity checks (Task 1) ===")
# bounds
assert ((risk_before_after["new_risk_score"].between(0,100)).all())
assert ((risk_before_after["new_risk_score_after"].between(0,100)).all())
assert ((risk_before_after["cvss_sum"] >= 0).all())
assert ((risk_before_after["cvss_sum_after"] >= 0).all())
# deltas consistent
assert np.allclose(
risk_before_after["delta_cvss"],
risk_before_after["cvss_sum_after"] - risk_before_after["cvss_sum"]
)
assert np.allclose(
risk_before_after["delta_new_score"],
risk_before_after["new_risk_score_after"] - risk_before_after["new_risk_score"]
)
# monotonicity (should never increase)
n_worse = int((risk_before_after["delta_cvss"] > 0).sum())
print(f"Assets that got worse (delta_cvss > 0): {n_worse}")
# quick counts
n = len(risk_before_after)
n_improved = int((risk_before_after["delta_cvss"] < 0).sum())
n_same = int((risk_before_after["delta_cvss"] == 0).sum())
print(f"Total assets: {n}")
print(f"Improved: {n_improved} ({100*n_improved/n:.2f}%)")
print(f"Unchanged: {n_same} ({100*n_same/n:.2f}%)")
print("All checks passed ")
=== Output sanity checks (Task 1) === Assets that got worse (delta_cvss > 0): 0 Total assets: 10468 Improved: 6104 (58.31%) Unchanged: 4364 (41.69%) All checks passed
Q3 – Simulation (Task 2): Distributional Impact of Remediation¶
Here, I examine how the overall risk distribution shifts before and after remediation.
Using the risk_before_after table constructed in Task 1, I compare the distributions of:
- new_risk_score (Before)
- new_risk_score_after (After)
The comparison is performed using several complementary views:
- Scatter Plot (Before vs After) – to visualize how individual assets move relative to the diagonal line (no change).
- Histogram Overlay – to compare the overall shape and mass of the two distributions.
- CDF (Cumulative Distribution Function) – to observe how the proportion of assets below any given risk threshold changes.
- Boxplot – to summarize central tendency and dispersion.
- Percentile and Threshold Analysis – to quantify shifts in key percentiles and operational risk thresholds.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# Guard
required_cols = {"new_risk_score", "new_risk_score_after"}
missing = required_cols - set(risk_before_after.columns)
assert not missing, f"risk_before_after is missing columns: {missing}. Run Task 1 first."
before = risk_before_after["new_risk_score"].astype(float).clip(0, 100)
after = risk_before_after["new_risk_score_after"].astype(float).clip(0, 100)
# Updated scatter: colored by category (IT vs Medical)
plt.figure(figsize=(7,6))
r_scatter = risk_before_after.copy()
r_scatter["asset_category"] = r_scatter["asset_category"].astype(str).str.strip().str.lower()
mask = r_scatter["new_risk_score"] < 80
# IT
it = r_scatter[mask & (r_scatter["asset_category"] == "it")]
plt.scatter(
it["new_risk_score"],
it["new_risk_score_after"],
alpha=0.35,
s=12,
label="IT"
)
# Medical
med = r_scatter[mask & (r_scatter["asset_category"] == "medical")]
plt.scatter(
med["new_risk_score"],
med["new_risk_score_after"],
alpha=0.35,
s=12,
label="Medical"
)
plt.plot([0,80], [0,80], linestyle="--", color="black", linewidth=1) # clear diagonal
plt.xlabel("New Risk Score (Before)")
plt.ylabel("New Risk Score (After)")
plt.title("Residual Risk (Zoomed: Before < 80)")
plt.legend()
plt.tight_layout()
plt.show()
# Histogram overlay
plt.figure(figsize=(7,5))
bins = np.linspace(0, 100, 41) # 40 bins
plt.hist(before, bins=bins, alpha=0.55, label="Before")
plt.hist(after, bins=bins, alpha=0.55, label="After")
plt.xlabel("New Risk Score")
plt.ylabel("Number of Assets")
plt.title("Distribution of Risk Scores: Before vs After")
plt.legend()
plt.tight_layout()
plt.show()
# CDF + Boxplot
def ecdf(x):
x = np.sort(np.asarray(x))
y = np.arange(1, len(x)+1) / len(x)
return x, y
x1, y1 = ecdf(before.values)
x2, y2 = ecdf(after.values)
plt.figure(figsize=(9,5))
plt.plot(x1, y1, label="Before")
plt.plot(x2, y2, label="After")
plt.title("CDF of New Risk Scores: Before vs After")
plt.xlabel("New Risk Score")
plt.ylabel("Fraction of Assets ≤ score")
plt.legend()
plt.grid(True, alpha=0.25)
plt.tight_layout()
plt.show()
plt.figure(figsize=(7,5))
plt.boxplot([before, after], tick_labels=["Before", "After"], showfliers=False)
plt.title("New Risk Score: Before vs After (Boxplot)")
plt.ylabel("New Risk Score")
plt.grid(True, axis="y", alpha=0.25)
plt.tight_layout()
plt.show()
# Numeric distribution summaries
summary = pd.DataFrame({
"metric": ["mean", "median", "p10", "p25", "p50", "p75", "p90", "p95"],
"before": [
before.mean(), before.median(),
np.percentile(before, 10), np.percentile(before, 25),
np.percentile(before, 50), np.percentile(before, 75),
np.percentile(before, 90), np.percentile(before, 95),
],
"after": [
after.mean(), after.median(),
np.percentile(after, 10), np.percentile(after, 25),
np.percentile(after, 50), np.percentile(after, 75),
np.percentile(after, 90), np.percentile(after, 95),
],
})
summary["delta (after-before)"] = summary["after"] - summary["before"]
summary[["before","after","delta (after-before)"]] = summary[["before","after","delta (after-before)"]].round(3)
print("\n=== Distribution Summary (New Risk Score) ===")
display(summary)
thresholds = [20, 40, 60, 80]
rows = []
for t in thresholds:
pct_before = 100 * (before >= t).mean()
pct_after = 100 * (after >= t).mean()
rows.append([t, round(pct_before, 2), round(pct_after, 2), round(pct_after - pct_before, 2)])
thr_df = pd.DataFrame(rows, columns=["threshold", "% before >= t", "% after >= t", "Δ (after-before)"])
print("\n=== Threshold Shifts (operational view) ===")
display(thr_df)
# Category-level breakdown (IT vs Medical)
r = risk_before_after.copy()
# flags
r["is_IT"] = r["asset_category"].astype(str).str.lower().eq("it")
r["is_Medical"] = r["asset_category"].astype(str).str.lower().eq("medical")
# category summary
cat_summary = (
r.groupby("asset_category")
.agg(
n_assets=("asset_id","count"),
mean_before=("new_risk_score","mean"),
mean_after=("new_risk_score_after","mean"),
median_before=("new_risk_score","median"),
median_after=("new_risk_score_after","median"),
pct_improved=("delta_cvss", lambda x: 100*(x < 0).mean()),
pct_high_before=("new_risk_score", lambda x: 100*(x >= 80).mean()),
pct_high_after=("new_risk_score_after", lambda x: 100*(x >= 80).mean()),
)
)
cat_summary["mean_delta"] = cat_summary["mean_after"] - cat_summary["mean_before"]
cat_summary["median_delta"] = cat_summary["median_after"] - cat_summary["median_before"]
cat_summary["high_delta"] = cat_summary["pct_high_after"] - cat_summary["pct_high_before"]
print("\n=== Category Breakdown (IT vs Medical) ===")
display(cat_summary.round(3))
=== Distribution Summary (New Risk Score) ===
| metric | before | after | delta (after-before) | |
|---|---|---|---|---|
| 0 | mean | 31.586 | 25.830 | -5.757 |
| 1 | median | 19.789 | 14.328 | -5.461 |
| 2 | p10 | 6.285 | 3.930 | -2.355 |
| 3 | p25 | 9.451 | 7.175 | -2.276 |
| 4 | p50 | 19.789 | 14.328 | -5.461 |
| 5 | p75 | 47.449 | 37.239 | -10.210 |
| 6 | p90 | 80.000 | 68.616 | -11.384 |
| 7 | p95 | 93.755 | 86.443 | -7.312 |
=== Threshold Shifts (operational view) ===
| threshold | % before >= t | % after >= t | Δ (after-before) | |
|---|---|---|---|---|
| 0 | 20 | 49.77 | 41.62 | -8.15 |
| 1 | 40 | 29.94 | 22.94 | -7.00 |
| 2 | 60 | 18.24 | 12.98 | -5.25 |
| 3 | 80 | 10.01 | 6.77 | -3.24 |
=== Category Breakdown (IT vs Medical) ===
| n_assets | mean_before | mean_after | median_before | median_after | pct_improved | pct_high_before | pct_high_after | mean_delta | median_delta | high_delta | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| asset_category | |||||||||||
| IT | 7663 | 31.498 | 27.627 | 19.866 | 16.428 | 43.051 | 9.709 | 7.386 | -3.871 | -3.438 | -2.323 |
| Medical | 2805 | 31.828 | 20.920 | 19.251 | 10.140 | 100.000 | 10.838 | 5.098 | -10.907 | -9.111 | -5.740 |
1. Overall Shift in the Distribution¶
The remediation policy shifts the risk distribution to lower values overall.
This is visible both numerically and visually:
- Mean risk score decreases: 31.59 → 25.83
- Median risk score decreases: 19.79 → 14.33
This indicates that the improvement is not limited to a few assets, but affects the general risk level across the environment.
2. Effect on High-Risk Assets¶
The upper part of the distribution also improves:
- p90 decreases from 80 → 68.6
- p95 decreases by more than 7 points
This means that the high-risk segment becomes smaller after remediation.
However, the decrease is more moderate near the extreme upper range, which is expected because the exponential scoring function becomes less sensitive close to its upper bound.
3. Scatter Plot Interpretation (Before vs After)¶
Most assets appear below the diagonal line, meaning their risk score decreased.
After zooming into the region ״Before < 80״, two clear patterns emerge:
- Medical assets follow a smooth downward curve, reflecting the uniform 50% reduction applied to all vulnerabilities.
- IT assets show a more scattered pattern, since only assets with high-priority vulnerabilities are affected.
This matches the remediation logic from Task 1:
- 100% of Medical assets are affected.
- ~43% of IT assets show improvement.
4. Distribution-Level View (CDF)¶
The CDF curve after remediation is consistently above the curve before remediation.
Interpretation: For any risk threshold, a larger fraction of assets falls below that threshold after applying the policy.
This suggests a broad distributional shift rather than isolated improvements.
5. Operational Threshold Impact¶
The percentage of assets above important risk thresholds decreases:
- ≥20 : −8.15%
- ≥40 : −7.00%
- ≥60 : −5.25%
- ≥80 : −3.24%
The smaller change at the highest threshold is expected, since very high scores are harder to reduce under a bounded exponential transformation.
6. Conclusion¶
Overall, the remediation strategy creates a clear leftward shift in the risk distribution.
- Medical assets show a strong and consistent improvement due to the uniform mitigation rule.
- IT assets improve selectively, depending on the presence of high-priority vulnerabilities.
The behavior observed in all plots and summary statistics is consistent with the remediation logic defined in Task 1, indicating that the simulation behaves as expected.
Task 2 – LLM-based CPS Model Enrichment¶
(a) Device Type and OS Identification – Workflow Setup¶
Before sending the models to the LLM, I start by preparing and standardizing the input data. Instead of querying the model with raw device names as-is, I first clean and normalize them to reduce noise and ambiguity. I remove unnecessary whitespace, check for duplicate entries, and extract vendor patterns (such as APC, Philips, Siemens, or Rockwell Automation). This step is important because LLM outputs are highly sensitive to input structure. So I reduce the risk of inconsistent classifications and improve the reliability of the enrichment process.
I started by loading the original list of CPS device models and performing basic preprocessing. This included converting the model names to strings, cleaning extra whitespace, and removing duplicate entries. The goal of this step was to create a clean and consistent list of unique device models that can be sent to the LLM for analysis.
import pandas as pd
import re
# Load
models = pd.read_csv("50_cps_models.csv")
models.head()
# Pre-processing
# Keep only the model column (and ensure it's string)
df = models.copy()
df["model"] = df["model"].astype(str)
# Clean names (strip whitespace + normalize spaces)
df["model_clean"] = (
df["model"]
.str.strip()
.str.replace(r"\s+", " ", regex=True)
)
# Remove duplicates (keep first occurrence)
df = df.drop_duplicates(subset=["model_clean"]).reset_index(drop=True)
# Vendor pattern detection
# (Rule: vendor = first token OR first two tokens if needed like "B.Braun" / "Johnson Controls" / "Rockwell Automation")
KNOWN_TWO_WORD_VENDORS = {
"johnson controls",
"rockwell automation",
"schneider electric",
"nova biomedical",
"becton dickinson",
"rauland-borg",
}
# Also handle dotted vendors like B.Braun
DOTTED_VENDOR_REGEX = r"^[A-Za-z]\.[A-Za-z][A-Za-z]*\b"
def extract_vendor(model_name: str) -> str:
s = model_name.strip()
if not s:
return "Unknown"
# Handle dotted vendor (e.g., "B.Braun ...")
if re.search(DOTTED_VENDOR_REGEX, s):
return s.split()[0] # "B.Braun"
parts = s.split()
if len(parts) == 1:
return parts[0]
first_two = f"{parts[0]} {parts[1]}".lower()
if first_two in KNOWN_TWO_WORD_VENDORS:
return f"{parts[0]} {parts[1]}"
# Default: first token vendor (e.g., APC, Philips, Siemens)
return parts[0]
df["vendor"] = df["model_clean"].apply(extract_vendor)
# show vendor distribution
vendor_counts = df["vendor"].value_counts().reset_index()
vendor_counts.columns = ["vendor", "n_models"]
print(f"Original rows: {len(models)}")
print(f"After de-dup: {len(df)}")
display(vendor_counts)
# cleaned table
df.head(10)
Original rows: 50 After de-dup: 50
| vendor | n_models | |
|---|---|---|
| 0 | APC | 11 |
| 1 | Philips | 5 |
| 2 | Baxter | 4 |
| 3 | Siemens | 3 |
| 4 | Alaris | 3 |
| 5 | Schneider Electric | 2 |
| 6 | Rockwell Automation | 2 |
| 7 | B.Braun | 2 |
| 8 | Carel | 2 |
| 9 | Hill-Rom | 1 |
| 10 | Eaton | 1 |
| 11 | Cerner | 1 |
| 12 | Becton Dickinson | 1 |
| 13 | Honeywell | 1 |
| 14 | Hospira | 1 |
| 15 | IGEL | 1 |
| 16 | Johnson Controls | 1 |
| 17 | Omnicell | 1 |
| 18 | Nova Biomedical | 1 |
| 19 | Microsystems | 1 |
| 20 | Lantronix | 1 |
| 21 | Roche | 1 |
| 22 | Rauland-Borg | 1 |
| 23 | Smiths | 1 |
| 24 | Swisslog | 1 |
| model | model_clean | vendor | |
|---|---|---|---|
| 0 | APC 0N-9582PANTERA | APC 0N-9582PANTERA | APC |
| 1 | APC AP7811B | APC AP7811B | APC |
| 2 | APC AP9630 | APC AP9630 | APC |
| 3 | APC AP9631 | APC AP9631 | APC |
| 4 | APC ATS | APC ATS | APC |
| 5 | APC MasterSwitchrPDU | APC MasterSwitchrPDU | APC |
| 6 | APC MasterSwitchrPDU2 | APC MasterSwitchrPDU2 | APC |
| 7 | APC Smart-UPS 1500 | APC Smart-UPS 1500 | APC |
| 8 | APC Smart-UPS SMT 1500 | APC Smart-UPS SMT 1500 | APC |
| 9 | APC Smart-UPS SRT 2200 | APC Smart-UPS SRT 2200 | APC |
Since the full list contains 50 device models, I split it into smaller batches of 10 models each. This was done to keep the prompts readable and to avoid overloading the LLM with too many devices at once. Each batch is processed independently, but all results are later merged back together.
Now, I generated structured prompts for both parts of the question using the same batching logic (10 device models per prompt). For Section (a), each prompt asked the model to act as a cybersecurity analyst and classify every device into a short device type category (e.g., UPS, Infusion Pump, HMI). I required the output to be a JSON array with the fields model and device_type, and explicitly instructed the model not to guess and to return "Unknown" when the device type was unclear.
For Section (b), I produced a parallel set of prompts for the same batches, but the task was to identify the operating system of each device only if it is publicly documented. Here, I required a JSON array with model, operating_system, and confidence (High / Medium / Low), and again instructed the model not to speculate and to return "Unknown" when reliable OS information was not available.
# Build batch prompts (10 models per prompt)
BATCH_SIZE = 10
def make_batches(series, batch_size=10):
items = series.tolist()
return [items[i:i+batch_size] for i in range(0, len(items), batch_size)]
models_list = df["model_clean"] # this is the clean column we want to send
batches = make_batches(models_list, BATCH_SIZE)
def prompt_device_type(batch):
models_block = "\n".join([f"- {m}" for m in batch])
return f"""You are a cybersecurity analyst specializing in industrial and medical CPS devices.
For each device model below, return its device type.
Rules:
- Output must be a JSON array of objects.
- Each object must contain the keys: "model", "device_type".
- "device_type" should be a short category name (e.g., "Infusion Pump", "UPS", "Patient Monitor", "PLC", "Thin Client").
- If unknown, return "Unknown".
- Do not guess. Only return a category if reasonably confident.
- JSON only. No extra text.
Device models:
{models_block}
"""
def prompt_operating_system(batch):
models_block = "\n".join([f"- {m}" for m in batch])
return f"""You are a cybersecurity analyst evaluating CPS devices.
For each device model below, identify the operating system if it is publicly documented.
Rules:
- Output must be a JSON array of objects.
- Each object must contain the keys: "model", "operating_system", "confidence".
- If not publicly known, return "Unknown".
- Do NOT speculate.
- If multiple firmware generations exist, return the most common known OS.
- confidence must be one of: High / Medium / Low
- JSON only. No extra text.
Device models:
{models_block}
"""
# Print prompts ready for copy/paste
print("========== SECTION (a): Device Type prompts ==========\n")
for i, batch in enumerate(batches, 1):
print(f"\n--- Prompt A{i} (models {((i-1)*BATCH_SIZE)+1}-{min(i*BATCH_SIZE, len(df))}) ---\n")
print(prompt_device_type(batch))
print("\n\n========== SECTION (b): Operating System prompts ==========\n")
for i, batch in enumerate(batches, 1):
print(f"\n--- Prompt B{i} (models {((i-1)*BATCH_SIZE)+1}-{min(i*BATCH_SIZE, len(df))}) ---\n")
print(prompt_operating_system(batch))
========== SECTION (a): Device Type prompts ========== --- Prompt A1 (models 1-10) --- You are a cybersecurity analyst specializing in industrial and medical CPS devices. For each device model below, return its device type. Rules: - Output must be a JSON array of objects. - Each object must contain the keys: "model", "device_type". - "device_type" should be a short category name (e.g., "Infusion Pump", "UPS", "Patient Monitor", "PLC", "Thin Client"). - If unknown, return "Unknown". - Do not guess. Only return a category if reasonably confident. - JSON only. No extra text. Device models: - APC 0N-9582PANTERA - APC AP7811B - APC AP9630 - APC AP9631 - APC ATS - APC MasterSwitchrPDU - APC MasterSwitchrPDU2 - APC Smart-UPS 1500 - APC Smart-UPS SMT 1500 - APC Smart-UPS SRT 2200 --- Prompt A2 (models 11-20) --- You are a cybersecurity analyst specializing in industrial and medical CPS devices. For each device model below, return its device type. Rules: - Output must be a JSON array of objects. - Each object must contain the keys: "model", "device_type". - "device_type" should be a short category name (e.g., "Infusion Pump", "UPS", "Patient Monitor", "PLC", "Thin Client"). - If unknown, return "Unknown". - Do not guess. Only return a category if reasonably confident. - JSON only. No extra text. Device models: - APC Smart-UPS SRT 5000 - Alaris 8015 PC Unit - Alaris 8100 Pump Module - Alaris 8110 Syringe Module - B.Braun Infusomat Space - B.Braun SmartBattery with Wifi - Baxter Novum IQ - Baxter Sigma Spectrum - Baxter Sigma Spectrum Wireless Module - Baxter Spectrum IQ --- Prompt A3 (models 21-30) --- You are a cybersecurity analyst specializing in industrial and medical CPS devices. For each device model below, return its device type. Rules: - Output must be a JSON array of objects. - Each object must contain the keys: "model", "device_type". - "device_type" should be a short category name (e.g., "Infusion Pump", "UPS", "Patient Monitor", "PLC", "Thin Client"). - If unknown, return "Unknown". - Do not guess. Only return a category if reasonably confident. - JSON only. No extra text. Device models: - Becton Dickinson Pyxis ES System - Carel PCO1000WB0 - Carel pCOWeb Card - Cerner Connectivity Engine - Eaton Tripp Lite Series - Hill-Rom NaviCare Room Control Board - Honeywell ComfortPoint Open CPO-VAV2A - Hospira Plum 360 - IGEL IGEL Thin Client - Johnson Controls NAE --- Prompt A4 (models 31-40) --- You are a cybersecurity analyst specializing in industrial and medical CPS devices. For each device model below, return its device type. Rules: - Output must be a JSON array of objects. - Each object must contain the keys: "model", "device_type". - "device_type" should be a short category name (e.g., "Infusion Pump", "UPS", "Patient Monitor", "PLC", "Thin Client"). - If unknown, return "Unknown". - Do not guess. Only return a category if reasonably confident. - JSON only. No extra text. Device models: - Lantronix MatchPort AR - Microsystems SPM Client - Nova Biomedical StatStrip - Omnicell Automated Dispensing Cabinet - Philips EarlyVue VS30 - Philips IntelliVue - Philips IntelliVue MX40 - Philips IntelliVue X3 - Philips PIIC - Rauland-Borg Responder --- Prompt A5 (models 41-50) --- You are a cybersecurity analyst specializing in industrial and medical CPS devices. For each device model below, return its device type. Rules: - Output must be a JSON array of objects. - Each object must contain the keys: "model", "device_type". - "device_type" should be a short category name (e.g., "Infusion Pump", "UPS", "Patient Monitor", "PLC", "Thin Client"). - If unknown, return "Unknown". - Do not guess. Only return a category if reasonably confident. - JSON only. No extra text. Device models: - Roche Accu-Chek Inform II - Rockwell Automation PanelView Plus - Rockwell Automation PanelView Plus 7 Performance 1000 - Schneider Electric AS series - Schneider Electric AS-P - Siemens PXC Modular - Siemens SIMATIC HMI - Siemens SIMATIC IPC - Smiths Medical MedFusion 4000 - Swisslog Translogic Tube Station ========== SECTION (b): Operating System prompts ========== --- Prompt B1 (models 1-10) --- You are a cybersecurity analyst evaluating CPS devices. For each device model below, identify the operating system if it is publicly documented. Rules: - Output must be a JSON array of objects. - Each object must contain the keys: "model", "operating_system", "confidence". - If not publicly known, return "Unknown". - Do NOT speculate. - If multiple firmware generations exist, return the most common known OS. - confidence must be one of: High / Medium / Low - JSON only. No extra text. Device models: - APC 0N-9582PANTERA - APC AP7811B - APC AP9630 - APC AP9631 - APC ATS - APC MasterSwitchrPDU - APC MasterSwitchrPDU2 - APC Smart-UPS 1500 - APC Smart-UPS SMT 1500 - APC Smart-UPS SRT 2200 --- Prompt B2 (models 11-20) --- You are a cybersecurity analyst evaluating CPS devices. For each device model below, identify the operating system if it is publicly documented. Rules: - Output must be a JSON array of objects. - Each object must contain the keys: "model", "operating_system", "confidence". - If not publicly known, return "Unknown". - Do NOT speculate. - If multiple firmware generations exist, return the most common known OS. - confidence must be one of: High / Medium / Low - JSON only. No extra text. Device models: - APC Smart-UPS SRT 5000 - Alaris 8015 PC Unit - Alaris 8100 Pump Module - Alaris 8110 Syringe Module - B.Braun Infusomat Space - B.Braun SmartBattery with Wifi - Baxter Novum IQ - Baxter Sigma Spectrum - Baxter Sigma Spectrum Wireless Module - Baxter Spectrum IQ --- Prompt B3 (models 21-30) --- You are a cybersecurity analyst evaluating CPS devices. For each device model below, identify the operating system if it is publicly documented. Rules: - Output must be a JSON array of objects. - Each object must contain the keys: "model", "operating_system", "confidence". - If not publicly known, return "Unknown". - Do NOT speculate. - If multiple firmware generations exist, return the most common known OS. - confidence must be one of: High / Medium / Low - JSON only. No extra text. Device models: - Becton Dickinson Pyxis ES System - Carel PCO1000WB0 - Carel pCOWeb Card - Cerner Connectivity Engine - Eaton Tripp Lite Series - Hill-Rom NaviCare Room Control Board - Honeywell ComfortPoint Open CPO-VAV2A - Hospira Plum 360 - IGEL IGEL Thin Client - Johnson Controls NAE --- Prompt B4 (models 31-40) --- You are a cybersecurity analyst evaluating CPS devices. For each device model below, identify the operating system if it is publicly documented. Rules: - Output must be a JSON array of objects. - Each object must contain the keys: "model", "operating_system", "confidence". - If not publicly known, return "Unknown". - Do NOT speculate. - If multiple firmware generations exist, return the most common known OS. - confidence must be one of: High / Medium / Low - JSON only. No extra text. Device models: - Lantronix MatchPort AR - Microsystems SPM Client - Nova Biomedical StatStrip - Omnicell Automated Dispensing Cabinet - Philips EarlyVue VS30 - Philips IntelliVue - Philips IntelliVue MX40 - Philips IntelliVue X3 - Philips PIIC - Rauland-Borg Responder --- Prompt B5 (models 41-50) --- You are a cybersecurity analyst evaluating CPS devices. For each device model below, identify the operating system if it is publicly documented. Rules: - Output must be a JSON array of objects. - Each object must contain the keys: "model", "operating_system", "confidence". - If not publicly known, return "Unknown". - Do NOT speculate. - If multiple firmware generations exist, return the most common known OS. - confidence must be one of: High / Medium / Low - JSON only. No extra text. Device models: - Roche Accu-Chek Inform II - Rockwell Automation PanelView Plus - Rockwell Automation PanelView Plus 7 Performance 1000 - Schneider Electric AS series - Schneider Electric AS-P - Siemens PXC Modular - Siemens SIMATIC HMI - Siemens SIMATIC IPC - Smiths Medical MedFusion 4000 - Swisslog Translogic Tube Station
After receiving the JSON outputs, I used helper functions to parse them into pandas DataFrames. Each JSON output was validated to ensure it contained the expected fields and structure.
I did it in order to catch formatting errors early (for example, missing fields or incorrect keys) and ensured that only valid results were added to the final tables.
# Paste JSON -> DataFrame -> accumulate results
import json
import pandas as pd
# Helpers
def json_to_df(json_text: str, expected_keys: list[str]) -> pd.DataFrame:
"""
Parse a JSON array (list of dicts) from LLM output and validate schema.
"""
json_text = json_text.strip()
# Sometimes models wrap JSON in ```json ... ```
if json_text.startswith("```"):
json_text = json_text.split("```", 2)[1] # take inside block
json_text = json_text.replace("json", "", 1).strip()
data = json.loads(json_text)
if not isinstance(data, list):
raise ValueError("Expected a JSON array (list of objects).")
# Validate keys
for i, obj in enumerate(data):
if not isinstance(obj, dict):
raise ValueError(f"Item {i} is not an object/dict.")
missing = set(expected_keys) - set(obj.keys())
if missing:
raise ValueError(f"Item {i} missing keys: {missing}. Got keys: {list(obj.keys())}")
return pd.DataFrame(data)
# Device Type accumulator
if "device_type_results" not in globals():
device_type_results = pd.DataFrame(columns=["model", "device_type"])
def add_device_type_batch(json_text: str):
global device_type_results
batch_df = json_to_df(json_text, expected_keys=["model", "device_type"])
# normalize
batch_df["model"] = batch_df["model"].astype(str).str.strip()
batch_df["device_type"] = batch_df["device_type"].astype(str).str.strip()
# append + de-dup by model (keep last in case you re-run a batch)
device_type_results = pd.concat([device_type_results, batch_df], ignore_index=True)
device_type_results = device_type_results.drop_duplicates(subset=["model"], keep="last").reset_index(drop=True)
print(f"Added {len(batch_df)} rows. Total device_type_results = {len(device_type_results)}")
display(device_type_results.tail(10))
# Operating System accumulator
if "os_results" not in globals():
os_results = pd.DataFrame(columns=["model", "operating_system", "confidence"])
def add_os_batch(json_text: str):
global os_results
batch_df = json_to_df(json_text, expected_keys=["model", "operating_system", "confidence"])
# normalize
batch_df["model"] = batch_df["model"].astype(str).str.strip()
batch_df["operating_system"] = batch_df["operating_system"].astype(str).str.strip()
batch_df["confidence"] = batch_df["confidence"].astype(str).str.strip()
# append + de-dup by model
os_results = pd.concat([os_results, batch_df], ignore_index=True)
os_results = os_results.drop_duplicates(subset=["model"], keep="last").reset_index(drop=True)
print(f"Added {len(batch_df)} rows. Total os_results = {len(os_results)}")
display(os_results.tail(10))
# Progress helper
def progress(expected_total=50):
print("Device types:", len(device_type_results), "/", expected_total)
print("OS results :", len(os_results), "/", expected_total)
# Save to CSV
def save_results():
device_type_results.to_csv("device_type_results.csv", index=False)
os_results.to_csv("os_results.csv", index=False)
print("Saved: device_type_results.csv, os_results.csv")
After generating the prompts, I worked in an iterative manual workflow.
For each batch, I copied the generated prompt from the previous chunk and sent it to a new chat session with the LLM.
Once the model returned a JSON response, I manually pasted the output back into Colab as a JSON string (e.g., json_text_a1, json_text_b1, etc.).
Each JSON batch was then parsed and added to a cumulative results table using dedicated helper functions:
- add_device_type_batch() for Section (a)
- add_os_batch() for Section (b)
So I gradually build the final structured datasets while keeping full control over the outputs and validating them batch by batch.
json_text_a1 = """
[
{"model": "APC 0N-9582PANTERA", "device_type": "Unknown"},
{"model": "APC AP7811B", "device_type": "PDU"},
{"model": "APC AP9630", "device_type": "Network Management Card"},
{"model": "APC AP9631", "device_type": "Network Management Card"},
{"model": "APC ATS", "device_type": "Automatic Transfer Switch"},
{"model": "APC MasterSwitchrPDU", "device_type": "PDU"},
{"model": "APC MasterSwitchrPDU2", "device_type": "PDU"},
{"model": "APC Smart-UPS 1500", "device_type": "UPS"},
{"model": "APC Smart-UPS SMT 1500", "device_type": "UPS"},
{"model": "APC Smart-UPS SRT 2200", "device_type": "UPS"}
]
"""
progress()
json_text_a2 = """
[
{
"model": "APC Smart-UPS SRT 5000",
"device_type": "UPS"
},
{
"model": "Alaris 8015 PC Unit",
"device_type": "Infusion Pump"
},
{
"model": "Alaris 8100 Pump Module",
"device_type": "Infusion Pump"
},
{
"model": "Alaris 8110 Syringe Module",
"device_type": "Syringe Pump"
},
{
"model": "B.Braun Infusomat Space",
"device_type": "Infusion Pump"
},
{
"model": "B.Braun SmartBattery with Wifi",
"device_type": "Battery Module"
},
{
"model": "Baxter Novum IQ",
"device_type": "Infusion Pump"
},
{
"model": "Baxter Sigma Spectrum",
"device_type": "Infusion Pump"
},
{
"model": "Baxter Sigma Spectrum Wireless Module",
"device_type": "Communication Module"
},
{
"model": "Baxter Spectrum IQ",
"device_type": "Infusion Pump"
}
]
"""
progress()
json_text_a3 = """
[
{
"model": "Becton Dickinson Pyxis ES System",
"device_type": "Automated Dispensing Cabinet"
},
{
"model": "Carel PCO1000WB0",
"device_type": "HVAC Controller"
},
{
"model": "Carel pCOWeb Card",
"device_type": "Communication Module"
},
{
"model": "Cerner Connectivity Engine",
"device_type": "Integration Engine"
},
{
"model": "Eaton Tripp Lite Series",
"device_type": "UPS"
},
{
"model": "Hill-Rom NaviCare Room Control Board",
"device_type": "Room Control System"
},
{
"model": "Honeywell ComfortPoint Open CPO-VAV2A",
"device_type": "VAV Controller"
},
{
"model": "Hospira Plum 360",
"device_type": "Infusion Pump"
},
{
"model": "IGEL IGEL Thin Client",
"device_type": "Thin Client"
},
{
"model": "Johnson Controls NAE",
"device_type": "Building Automation Controller"
}
]
"""
progress()
json_text_a4 = """
[
{
"model": "Lantronix MatchPort AR",
"device_type": "Device Server"
},
{
"model": "Microsystems SPM Client",
"device_type": "Unknown"
},
{
"model": "Nova Biomedical StatStrip",
"device_type": "Blood Glucose Meter"
},
{
"model": "Omnicell Automated Dispensing Cabinet",
"device_type": "Automated Dispensing Cabinet"
},
{
"model": "Philips EarlyVue VS30",
"device_type": "Vital Signs Monitor"
},
{
"model": "Philips IntelliVue",
"device_type": "Patient Monitor"
},
{
"model": "Philips IntelliVue MX40",
"device_type": "Telemetry Monitor"
},
{
"model": "Philips IntelliVue X3",
"device_type": "Patient Monitor"
},
{
"model": "Philips PIIC",
"device_type": "Central Monitoring Station"
},
{
"model": "Rauland-Borg Responder",
"device_type": "Nurse Call System"
}
]
"""
progress()
json_text_a5 = """
[
{
"model": "Roche Accu-Chek Inform II",
"device_type": "Blood Glucose Meter"
},
{
"model": "Rockwell Automation PanelView Plus",
"device_type": "HMI"
},
{
"model": "Rockwell Automation PanelView Plus 7 Performance 1000",
"device_type": "HMI"
},
{
"model": "Schneider Electric AS series",
"device_type": "PLC"
},
{
"model": "Schneider Electric AS-P",
"device_type": "PLC"
},
{
"model": "Siemens PXC Modular",
"device_type": "Building Automation Controller"
},
{
"model": "Siemens SIMATIC HMI",
"device_type": "HMI"
},
{
"model": "Siemens SIMATIC IPC",
"device_type": "Industrial PC"
},
{
"model": "Smiths Medical MedFusion 4000",
"device_type": "Infusion Pump"
},
{
"model": "Swisslog Translogic Tube Station",
"device_type": "Pneumatic Tube System"
}
]
"""
progress()
json_text_b1 = """
[
{
"model": "APC 0N-9582PANTERA",
"operating_system": "Unknown",
"confidence": "Low"
},
{
"model": "APC AP7811B",
"operating_system": "Unknown",
"confidence": "Low"
},
{
"model": "APC AP9630",
"operating_system": "APC Operating System (AOS)",
"confidence": "High"
},
{
"model": "APC AP9631",
"operating_system": "APC Operating System (AOS)",
"confidence": "High"
},
{
"model": "APC ATS",
"operating_system": "Unknown",
"confidence": "Low"
},
{
"model": "APC MasterSwitchrPDU",
"operating_system": "APC Operating System (AOS)",
"confidence": "Medium"
},
{
"model": "APC MasterSwitchrPDU2",
"operating_system": "APC Operating System (AOS)",
"confidence": "Medium"
},
{
"model": "APC Smart-UPS 1500",
"operating_system": "Unknown",
"confidence": "Low"
},
{
"model": "APC Smart-UPS SMT 1500",
"operating_system": "Unknown",
"confidence": "Low"
},
{
"model": "APC Smart-UPS SRT 2200",
"operating_system": "Unknown",
"confidence": "Low"
}
]
"""
progress()
json_text_b2 = """
[
{
"model": "APC Smart-UPS SRT 5000",
"operating_system": "APC Operating System (AOS)",
"confidence": "High"
},
{
"model": "Alaris 8015 PC Unit",
"operating_system": "Alaris Operating System software (v9.x)",
"confidence": "Medium"
},
{
"model": "Alaris 8100 Pump Module",
"operating_system": "Alaris Operating System software (v9.x)",
"confidence": "Medium"
},
{
"model": "Alaris 8110 Syringe Module",
"operating_system": "Alaris Operating System software (v9.x)",
"confidence": "Medium"
},
{
"model": "B.Braun Infusomat Space",
"operating_system": "Unknown",
"confidence": "Low"
},
{
"model": "B.Braun SmartBattery with Wifi",
"operating_system": "Unknown",
"confidence": "Low"
},
{
"model": "Baxter Novum IQ",
"operating_system": "Unknown",
"confidence": "Low"
},
{
"model": "Baxter Sigma Spectrum",
"operating_system": "Unknown",
"confidence": "Low"
},
{
"model": "Baxter Sigma Spectrum Wireless Module",
"operating_system": "Digi NET+OS",
"confidence": "High"
},
{
"model": "Baxter Spectrum IQ",
"operating_system": "Digi NET+OS",
"confidence": "High"
}
]
"""
progress()
json_text_b3 = """
[
{
"model": "Becton Dickinson Pyxis ES System",
"operating_system": "Windows 10 IoT",
"confidence": "High"
},
{
"model": "Carel PCO1000WB0",
"operating_system": "Linux",
"confidence": "Medium"
},
{
"model": "Carel pCOWeb Card",
"operating_system": "Linux",
"confidence": "High"
},
{
"model": "Cerner Connectivity Engine",
"operating_system": "Unknown",
"confidence": "Low"
},
{
"model": "Eaton Tripp Lite Series",
"operating_system": "Unknown",
"confidence": "Low"
},
{
"model": "Hill-Rom NaviCare Room Control Board",
"operating_system": "Unknown",
"confidence": "Low"
},
{
"model": "Honeywell ComfortPoint Open CPO-VAV2A",
"operating_system": "Unknown",
"confidence": "Low"
},
{
"model": "Hospira Plum 360",
"operating_system": "Unknown",
"confidence": "Low"
},
{
"model": "IGEL IGEL Thin Client",
"operating_system": "IGEL OS (Linux-based)",
"confidence": "High"
},
{
"model": "Johnson Controls NAE",
"operating_system": "Windows Embedded Standard 7 (WES7) SP1",
"confidence": "High"
}
]
"""
progress()
json_text_b4 = """
[
{
"model": "Lantronix MatchPort AR",
"operating_system": "Lantronix Evolution OS ",
"confidence": "High"
},
{
"model": "Microsystems SPM Client",
"operating_system": "Unknown",
"confidence": "Low"
},
{
"model": "Nova Biomedical StatStrip",
"operating_system": "Windows CE ",
"confidence": "High"
},
{
"model": "Omnicell Automated Dispensing Cabinet",
"operating_system": "Windows 10 ",
"confidence": "Medium"
},
{
"model": "Philips EarlyVue VS30",
"operating_system": "Unknown",
"confidence": "Low"
},
{
"model": "Philips IntelliVue",
"operating_system": "Unknown",
"confidence": "Low"
},
{
"model": "Philips IntelliVue MX40",
"operating_system": "Unknown",
"confidence": "Low"
},
{
"model": "Philips IntelliVue X3",
"operating_system": "Unknown",
"confidence": "Low"
},
{
"model": "Philips PIIC",
"operating_system": "Windows Server 2012 R2 ",
"confidence": "High"
},
{
"model": "Rauland-Borg Responder",
"operating_system": "Windows ",
"confidence": "Medium"
}
]
"""
progress()
json_text_b5 = """
[
{
"model": "Roche Accu-Chek Inform II",
"operating_system": "Unknown",
"confidence": "Low"
},
{
"model": "Rockwell Automation PanelView Plus",
"operating_system": "Microsoft Windows CE",
"confidence": "High"
},
{
"model": "Rockwell Automation PanelView Plus 7 Performance 1000",
"operating_system": "Windows 10 IoT Core (LTSC)",
"confidence": "High"
},
{
"model": "Schneider Electric AS series",
"operating_system": "Linux",
"confidence": "High"
},
{
"model": "Schneider Electric AS-P",
"operating_system": "Unknown",
"confidence": "Low"
},
{
"model": "Siemens PXC Modular",
"operating_system": "Unknown",
"confidence": "Low"
},
{
"model": "Siemens SIMATIC HMI",
"operating_system": "Microsoft Windows CE",
"confidence": "High"
},
{
"model": "Siemens SIMATIC IPC",
"operating_system": "Windows",
"confidence": "High"
},
{
"model": "Smiths Medical MedFusion 4000",
"operating_system": "Unknown",
"confidence": "Low"
},
{
"model": "Swisslog Translogic Tube Station",
"operating_system": "Linux",
"confidence": "Medium"
}
]
"""
progress()
Device types: 0 / 50 OS results : 0 / 50 Device types: 0 / 50 OS results : 0 / 50 Device types: 0 / 50 OS results : 0 / 50 Device types: 0 / 50 OS results : 0 / 50 Device types: 0 / 50 OS results : 0 / 50 Device types: 0 / 50 OS results : 0 / 50 Device types: 0 / 50 OS results : 0 / 50 Device types: 0 / 50 OS results : 0 / 50 Device types: 0 / 50 OS results : 0 / 50 Device types: 0 / 50 OS results : 0 / 50
add_device_type_batch(json_text_a1)
add_device_type_batch(json_text_a2)
add_device_type_batch(json_text_a3)
add_device_type_batch(json_text_a4)
add_device_type_batch(json_text_a5)
add_os_batch(json_text_b1)
add_os_batch(json_text_b2)
add_os_batch(json_text_b3)
add_os_batch(json_text_b4)
add_os_batch(json_text_b5)
Added 10 rows. Total device_type_results = 10
| model | device_type | |
|---|---|---|
| 0 | APC 0N-9582PANTERA | Unknown |
| 1 | APC AP7811B | PDU |
| 2 | APC AP9630 | Network Management Card |
| 3 | APC AP9631 | Network Management Card |
| 4 | APC ATS | Automatic Transfer Switch |
| 5 | APC MasterSwitchrPDU | PDU |
| 6 | APC MasterSwitchrPDU2 | PDU |
| 7 | APC Smart-UPS 1500 | UPS |
| 8 | APC Smart-UPS SMT 1500 | UPS |
| 9 | APC Smart-UPS SRT 2200 | UPS |
Added 10 rows. Total device_type_results = 20
| model | device_type | |
|---|---|---|
| 10 | APC Smart-UPS SRT 5000 | UPS |
| 11 | Alaris 8015 PC Unit | Infusion Pump |
| 12 | Alaris 8100 Pump Module | Infusion Pump |
| 13 | Alaris 8110 Syringe Module | Syringe Pump |
| 14 | B.Braun Infusomat Space | Infusion Pump |
| 15 | B.Braun SmartBattery with Wifi | Battery Module |
| 16 | Baxter Novum IQ | Infusion Pump |
| 17 | Baxter Sigma Spectrum | Infusion Pump |
| 18 | Baxter Sigma Spectrum Wireless Module | Communication Module |
| 19 | Baxter Spectrum IQ | Infusion Pump |
Added 10 rows. Total device_type_results = 30
| model | device_type | |
|---|---|---|
| 20 | Becton Dickinson Pyxis ES System | Automated Dispensing Cabinet |
| 21 | Carel PCO1000WB0 | HVAC Controller |
| 22 | Carel pCOWeb Card | Communication Module |
| 23 | Cerner Connectivity Engine | Integration Engine |
| 24 | Eaton Tripp Lite Series | UPS |
| 25 | Hill-Rom NaviCare Room Control Board | Room Control System |
| 26 | Honeywell ComfortPoint Open CPO-VAV2A | VAV Controller |
| 27 | Hospira Plum 360 | Infusion Pump |
| 28 | IGEL IGEL Thin Client | Thin Client |
| 29 | Johnson Controls NAE | Building Automation Controller |
Added 10 rows. Total device_type_results = 40
| model | device_type | |
|---|---|---|
| 30 | Lantronix MatchPort AR | Device Server |
| 31 | Microsystems SPM Client | Unknown |
| 32 | Nova Biomedical StatStrip | Blood Glucose Meter |
| 33 | Omnicell Automated Dispensing Cabinet | Automated Dispensing Cabinet |
| 34 | Philips EarlyVue VS30 | Vital Signs Monitor |
| 35 | Philips IntelliVue | Patient Monitor |
| 36 | Philips IntelliVue MX40 | Telemetry Monitor |
| 37 | Philips IntelliVue X3 | Patient Monitor |
| 38 | Philips PIIC | Central Monitoring Station |
| 39 | Rauland-Borg Responder | Nurse Call System |
Added 10 rows. Total device_type_results = 50
| model | device_type | |
|---|---|---|
| 40 | Roche Accu-Chek Inform II | Blood Glucose Meter |
| 41 | Rockwell Automation PanelView Plus | HMI |
| 42 | Rockwell Automation PanelView Plus 7 Performan... | HMI |
| 43 | Schneider Electric AS series | PLC |
| 44 | Schneider Electric AS-P | PLC |
| 45 | Siemens PXC Modular | Building Automation Controller |
| 46 | Siemens SIMATIC HMI | HMI |
| 47 | Siemens SIMATIC IPC | Industrial PC |
| 48 | Smiths Medical MedFusion 4000 | Infusion Pump |
| 49 | Swisslog Translogic Tube Station | Pneumatic Tube System |
Added 10 rows. Total os_results = 10
| model | operating_system | confidence | |
|---|---|---|---|
| 0 | APC 0N-9582PANTERA | Unknown | Low |
| 1 | APC AP7811B | Unknown | Low |
| 2 | APC AP9630 | APC Operating System (AOS) | High |
| 3 | APC AP9631 | APC Operating System (AOS) | High |
| 4 | APC ATS | Unknown | Low |
| 5 | APC MasterSwitchrPDU | APC Operating System (AOS) | Medium |
| 6 | APC MasterSwitchrPDU2 | APC Operating System (AOS) | Medium |
| 7 | APC Smart-UPS 1500 | Unknown | Low |
| 8 | APC Smart-UPS SMT 1500 | Unknown | Low |
| 9 | APC Smart-UPS SRT 2200 | Unknown | Low |
Added 10 rows. Total os_results = 20
| model | operating_system | confidence | |
|---|---|---|---|
| 10 | APC Smart-UPS SRT 5000 | APC Operating System (AOS) | High |
| 11 | Alaris 8015 PC Unit | Alaris Operating System software (v9.x) | Medium |
| 12 | Alaris 8100 Pump Module | Alaris Operating System software (v9.x) | Medium |
| 13 | Alaris 8110 Syringe Module | Alaris Operating System software (v9.x) | Medium |
| 14 | B.Braun Infusomat Space | Unknown | Low |
| 15 | B.Braun SmartBattery with Wifi | Unknown | Low |
| 16 | Baxter Novum IQ | Unknown | Low |
| 17 | Baxter Sigma Spectrum | Unknown | Low |
| 18 | Baxter Sigma Spectrum Wireless Module | Digi NET+OS | High |
| 19 | Baxter Spectrum IQ | Digi NET+OS | High |
Added 10 rows. Total os_results = 30
| model | operating_system | confidence | |
|---|---|---|---|
| 20 | Becton Dickinson Pyxis ES System | Windows 10 IoT | High |
| 21 | Carel PCO1000WB0 | Linux | Medium |
| 22 | Carel pCOWeb Card | Linux | High |
| 23 | Cerner Connectivity Engine | Unknown | Low |
| 24 | Eaton Tripp Lite Series | Unknown | Low |
| 25 | Hill-Rom NaviCare Room Control Board | Unknown | Low |
| 26 | Honeywell ComfortPoint Open CPO-VAV2A | Unknown | Low |
| 27 | Hospira Plum 360 | Unknown | Low |
| 28 | IGEL IGEL Thin Client | IGEL OS (Linux-based) | High |
| 29 | Johnson Controls NAE | Windows Embedded Standard 7 (WES7) SP1 | High |
Added 10 rows. Total os_results = 40
| model | operating_system | confidence | |
|---|---|---|---|
| 30 | Lantronix MatchPort AR | Lantronix Evolution OS | High |
| 31 | Microsystems SPM Client | Unknown | Low |
| 32 | Nova Biomedical StatStrip | Windows CE | High |
| 33 | Omnicell Automated Dispensing Cabinet | Windows 10 | Medium |
| 34 | Philips EarlyVue VS30 | Unknown | Low |
| 35 | Philips IntelliVue | Unknown | Low |
| 36 | Philips IntelliVue MX40 | Unknown | Low |
| 37 | Philips IntelliVue X3 | Unknown | Low |
| 38 | Philips PIIC | Windows Server 2012 R2 | High |
| 39 | Rauland-Borg Responder | Windows | Medium |
Added 10 rows. Total os_results = 50
| model | operating_system | confidence | |
|---|---|---|---|
| 40 | Roche Accu-Chek Inform II | Unknown | Low |
| 41 | Rockwell Automation PanelView Plus | Microsoft Windows CE | High |
| 42 | Rockwell Automation PanelView Plus 7 Performan... | Windows 10 IoT Core (LTSC) | High |
| 43 | Schneider Electric AS series | Linux | High |
| 44 | Schneider Electric AS-P | Unknown | Low |
| 45 | Siemens PXC Modular | Unknown | Low |
| 46 | Siemens SIMATIC HMI | Microsoft Windows CE | High |
| 47 | Siemens SIMATIC IPC | Windows | High |
| 48 | Smiths Medical MedFusion 4000 | Unknown | Low |
| 49 | Swisslog Translogic Tube Station | Linux | Medium |
device_type_results.tail(3)
print("Total rows:", len(device_type_results))
print("Unique models:", device_type_results["model"].nunique())
Total rows: 50 Unique models: 50
I saved the two consolidated result tables as CSV files in the working directory (\content).
I then opened the files to verify that both outputs were successfully created and contained the expected final results.
The evice types were kept exactly as returned by the LLM and were not post-normalized.
Since the assignment focuses on enrichment rather than taxonomy design, no manual unification between similar categories (e.g., Infusion Pump vs Syringe Pump) was applied.
def save_results():
device_type_results.to_csv("/content/device_type_results.csv", index=False)
os_results.to_csv("/content/os_results.csv", index=False)
print("Saved inside content")
save_results()
Saved inside content
Section (c): Comparing Gemini vs ChatGPT on OS identification¶
I ran the exact same 5 OS prompts (B1–B5) on Gemini and collected its JSON outputs. Then, I parsed the results into a separate table (os_results_gemini) using the same validation logic as before. Finally, I compared Gemini’s OS + confidence outputs against the existing ChatGPT table (os_results) and summarized agreements and disagreements per device model.
This comparison measures coverage and consistency (Known vs Unknown and agreement), not verified correctness, because no truth OS labels were provided to me.
# Gemini OS results + comparison vs ChatGPT
import json
import pandas as pd
def json_to_df(json_text: str, expected_keys: list[str]) -> pd.DataFrame:
json_text = json_text.strip()
# handle ```json ... ```
if json_text.startswith("```"):
json_text = json_text.split("```", 2)[1]
json_text = json_text.replace("json", "", 1).strip()
data = json.loads(json_text)
if not isinstance(data, list):
raise ValueError("Expected a JSON array (list of objects).")
for i, obj in enumerate(data):
if not isinstance(obj, dict):
raise ValueError(f"Item {i} is not an object/dict.")
missing = set(expected_keys) - set(obj.keys())
if missing:
raise ValueError(f"Item {i} missing keys: {missing}. Got keys: {list(obj.keys())}")
return pd.DataFrame(data)
# Gemini Operating System accumulator
if "os_results_gemini" not in globals():
os_results_gemini = pd.DataFrame(columns=["model", "operating_system", "confidence"])
def add_os_batch_gemini(json_text: str):
global os_results_gemini
batch_df = json_to_df(json_text, expected_keys=["model", "operating_system", "confidence"])
# normalize
batch_df["model"] = batch_df["model"].astype(str).str.strip()
batch_df["operating_system"] = batch_df["operating_system"].astype(str).str.strip()
batch_df["confidence"] = batch_df["confidence"].astype(str).str.strip()
# append + de-dup by model (keep last)
os_results_gemini = pd.concat([os_results_gemini, batch_df], ignore_index=True)
os_results_gemini = os_results_gemini.drop_duplicates(subset=["model"], keep="last").reset_index(drop=True)
print(f"Added {len(batch_df)} rows. Total os_results_gemini = {len(os_results_gemini)}")
display(os_results_gemini.tail(10))
# Comparison: ChatGPT vs Gemini (Section b)
def compare_os_chatgpt_vs_gemini():
if "os_results" not in globals():
raise ValueError("os_results (ChatGPT) not found. Run your ChatGPT section (b) first.")
if "os_results_gemini" not in globals() or len(os_results_gemini) == 0:
raise ValueError("os_results_gemini is empty. Paste Gemini batches first.")
a = os_results.rename(columns={
"operating_system": "os_chatgpt",
"confidence": "conf_chatgpt"
}).copy()
b = os_results_gemini.rename(columns={
"operating_system": "os_gemini",
"confidence": "conf_gemini"
}).copy()
comp = a.merge(b, on="model", how="outer")
# flags
comp["both_unknown"] = (comp["os_chatgpt"].fillna("Unknown") == "Unknown") & (comp["os_gemini"].fillna("Unknown") == "Unknown")
comp["agree_exact"] = (comp["os_chatgpt"].fillna("") == comp["os_gemini"].fillna(""))
comp["disagree"] = (~comp["agree_exact"]) & (~comp["both_unknown"])
# simple summary counts
summary = {
"total_models_union": int(len(comp)),
"exact_agreement": int(comp["agree_exact"].sum()),
"both_unknown": int(comp["both_unknown"].sum()),
"disagreements_non_unknown": int(comp["disagree"].sum()),
"chatgpt_known_gemini_unknown": int(((comp["os_chatgpt"].fillna("Unknown") != "Unknown") & (comp["os_gemini"].fillna("Unknown") == "Unknown")).sum()),
"gemini_known_chatgpt_unknown": int(((comp["os_gemini"].fillna("Unknown") != "Unknown") & (comp["os_chatgpt"].fillna("Unknown") == "Unknown")).sum()),
}
# show disagreements table
disagreements_df = comp[comp["disagree"]].sort_values("model")[["model", "os_chatgpt", "conf_chatgpt", "os_gemini", "conf_gemini"]]
print("=== Comparison summary (Section b) ===")
for k, v in summary.items():
print(f"{k}: {v}")
display(disagreements_df)
return comp, disagreements_df, summary
json_text_g1 = """
[
{
"model": "APC 0N-9582PANTERA",
"operating_system": "Unknown",
"confidence": "Low"
},
{
"model": "APC AP7811B",
"operating_system": "APC OS (AOS)",
"confidence": "High"
},
{
"model": "APC AP9630",
"operating_system": "APC OS (AOS)",
"confidence": "High"
},
{
"model": "APC AP9631",
"operating_system": "APC OS (AOS)",
"confidence": "High"
},
{
"model": "APC ATS",
"operating_system": "APC OS (AOS)",
"confidence": "High"
},
{
"model": "APC MasterSwitchrPDU",
"operating_system": "APC OS (AOS)",
"confidence": "Medium"
},
{
"model": "APC MasterSwitchrPDU2",
"operating_system": "APC OS (AOS)",
"confidence": "Medium"
},
{
"model": "APC Smart-UPS 1500",
"operating_system": "Proprietary Firmware",
"confidence": "High"
},
{
"model": "APC Smart-UPS SMT 1500",
"operating_system": "Proprietary Firmware",
"confidence": "High"
},
{
"model": "APC Smart-UPS SRT 2200",
"operating_system": "Proprietary Firmware",
"confidence": "High"
}
]
"""
json_text_g2 = """
[
{
"model": "APC Smart-UPS SRT 5000",
"operating_system": "APC OS (Proprietary)",
"confidence": "High"
},
{
"model": "Alaris 8015 PC Unit",
"operating_system": "Windows CE",
"confidence": "High"
},
{
"model": "Alaris 8100 Pump Module",
"operating_system": "Unknown",
"confidence": "Low"
},
{
"model": "Alaris 8110 Syringe Module",
"operating_system": "Unknown",
"confidence": "Low"
},
{
"model": "B.Braun Infusomat Space",
"operating_system": "Embedded Linux",
"confidence": "High"
},
{
"model": "B.Braun SmartBattery with Wifi",
"operating_system": "Embedded Linux",
"confidence": "Medium"
},
{
"model": "Baxter Novum IQ",
"operating_system": "Embedded Linux",
"confidence": "High"
},
{
"model": "Baxter Sigma Spectrum",
"operating_system": "Unknown",
"confidence": "Low"
},
{
"model": "Baxter Sigma Spectrum Wireless Module",
"operating_system": "Unknown",
"confidence": "Low"
},
{
"model": "Baxter Spectrum IQ",
"operating_system": "Embedded Linux",
"confidence": "High"
}
]
"""
json_text_g3 = """
[
{
"model": "Becton Dickinson Pyxis ES System",
"operating_system": "Windows 10 IoT Enterprise",
"confidence": "High"
},
{
"model": "Carel PCO1000WB0",
"operating_system": "FreeRTOS",
"confidence": "Medium"
},
{
"model": "Carel pCOWeb Card",
"operating_system": "Linux",
"confidence": "High"
},
{
"model": "Cerner Connectivity Engine",
"operating_system": "Linux",
"confidence": "Medium"
},
{
"model": "Eaton Tripp Lite Series",
"operating_system": "Unknown",
"confidence": "Low"
},
{
"model": "Hill-Rom NaviCare Room Control Board",
"operating_system": "Windows Embedded Compact",
"confidence": "Medium"
},
{
"model": "Honeywell ComfortPoint Open CPO-VAV2A",
"operating_system": "Unknown",
"confidence": "Low"
},
{
"model": "Hospira Plum 360",
"operating_system": "VxWorks",
"confidence": "High"
},
{
"model": "IGEL IGEL Thin Client",
"operating_system": "IGEL OS (Linux-based)",
"confidence": "High"
},
{
"model": "Johnson Controls NAE",
"operating_system": "Windows Embedded",
"confidence": "High"
}
]
"""
json_text_g4 = """
[
{
"model": "Lantronix MatchPort AR",
"operating_system": "Evolution OS",
"confidence": "High"
},
{
"model": "Microsystems SPM Client",
"operating_system": "Windows",
"confidence": "High"
},
{
"model": "Nova Biomedical StatStrip",
"operating_system": "Unknown",
"confidence": "High"
},
{
"model": "Omnicell Automated Dispensing Cabinet",
"operating_system": "Windows 10 IoT Enterprise",
"confidence": "High"
},
{
"model": "Philips EarlyVue VS30",
"operating_system": "Linux",
"confidence": "High"
},
{
"model": "Philips IntelliVue",
"operating_system": "Proprietary RTOS",
"confidence": "Medium"
},
{
"model": "Philips IntelliVue MX40",
"operating_system": "Unknown",
"confidence": "High"
},
{
"model": "Philips IntelliVue X3",
"operating_system": "Unknown",
"confidence": "High"
},
{
"model": "Philips PIIC",
"operating_system": "Windows Server",
"confidence": "High"
},
{
"model": "Rauland-Borg Responder",
"operating_system": "Unknown",
"confidence": "High"
}
]
"""
json_text_g5 = """
[
{
"model": "Roche Accu-Chek Inform II",
"operating_system": "Unknown",
"confidence": "Low"
},
{
"model": "Rockwell Automation PanelView Plus",
"operating_system": "Windows CE",
"confidence": "High"
},
{
"model": "Rockwell Automation PanelView Plus 7 Performance 1000",
"operating_system": "Windows 10 IoT Core",
"confidence": "High"
},
{
"model": "Schneider Electric AS series",
"operating_system": "Linux",
"confidence": "Medium"
},
{
"model": "Schneider Electric AS-P",
"operating_system": "Linux",
"confidence": "High"
},
{
"model": "Siemens PXC Modular",
"operating_system": "Nucleus RTOS",
"confidence": "High"
},
{
"model": "Siemens SIMATIC HMI",
"operating_system": "Windows CE",
"confidence": "Medium"
},
{
"model": "Siemens SIMATIC IPC",
"operating_system": "Windows 10 Enterprise LTSC",
"confidence": "High"
},
{
"model": "Smiths Medical MedFusion 4000",
"operating_system": "Unknown",
"confidence": "Low"
},
{
"model": "Swisslog Translogic Tube Station",
"operating_system": "Linux",
"confidence": "High"
}
]
"""
add_os_batch_gemini(json_text_g1)
add_os_batch_gemini(json_text_g2)
add_os_batch_gemini(json_text_g3)
add_os_batch_gemini(json_text_g4)
add_os_batch_gemini(json_text_g5)
Added 10 rows. Total os_results_gemini = 10
| model | operating_system | confidence | |
|---|---|---|---|
| 0 | APC 0N-9582PANTERA | Unknown | Low |
| 1 | APC AP7811B | APC OS (AOS) | High |
| 2 | APC AP9630 | APC OS (AOS) | High |
| 3 | APC AP9631 | APC OS (AOS) | High |
| 4 | APC ATS | APC OS (AOS) | High |
| 5 | APC MasterSwitchrPDU | APC OS (AOS) | Medium |
| 6 | APC MasterSwitchrPDU2 | APC OS (AOS) | Medium |
| 7 | APC Smart-UPS 1500 | Proprietary Firmware | High |
| 8 | APC Smart-UPS SMT 1500 | Proprietary Firmware | High |
| 9 | APC Smart-UPS SRT 2200 | Proprietary Firmware | High |
Added 10 rows. Total os_results_gemini = 20
| model | operating_system | confidence | |
|---|---|---|---|
| 10 | APC Smart-UPS SRT 5000 | APC OS (Proprietary) | High |
| 11 | Alaris 8015 PC Unit | Windows CE | High |
| 12 | Alaris 8100 Pump Module | Unknown | Low |
| 13 | Alaris 8110 Syringe Module | Unknown | Low |
| 14 | B.Braun Infusomat Space | Embedded Linux | High |
| 15 | B.Braun SmartBattery with Wifi | Embedded Linux | Medium |
| 16 | Baxter Novum IQ | Embedded Linux | High |
| 17 | Baxter Sigma Spectrum | Unknown | Low |
| 18 | Baxter Sigma Spectrum Wireless Module | Unknown | Low |
| 19 | Baxter Spectrum IQ | Embedded Linux | High |
Added 10 rows. Total os_results_gemini = 30
| model | operating_system | confidence | |
|---|---|---|---|
| 20 | Becton Dickinson Pyxis ES System | Windows 10 IoT Enterprise | High |
| 21 | Carel PCO1000WB0 | FreeRTOS | Medium |
| 22 | Carel pCOWeb Card | Linux | High |
| 23 | Cerner Connectivity Engine | Linux | Medium |
| 24 | Eaton Tripp Lite Series | Unknown | Low |
| 25 | Hill-Rom NaviCare Room Control Board | Windows Embedded Compact | Medium |
| 26 | Honeywell ComfortPoint Open CPO-VAV2A | Unknown | Low |
| 27 | Hospira Plum 360 | VxWorks | High |
| 28 | IGEL IGEL Thin Client | IGEL OS (Linux-based) | High |
| 29 | Johnson Controls NAE | Windows Embedded | High |
Added 10 rows. Total os_results_gemini = 40
| model | operating_system | confidence | |
|---|---|---|---|
| 30 | Lantronix MatchPort AR | Evolution OS | High |
| 31 | Microsystems SPM Client | Windows | High |
| 32 | Nova Biomedical StatStrip | Unknown | High |
| 33 | Omnicell Automated Dispensing Cabinet | Windows 10 IoT Enterprise | High |
| 34 | Philips EarlyVue VS30 | Linux | High |
| 35 | Philips IntelliVue | Proprietary RTOS | Medium |
| 36 | Philips IntelliVue MX40 | Unknown | High |
| 37 | Philips IntelliVue X3 | Unknown | High |
| 38 | Philips PIIC | Windows Server | High |
| 39 | Rauland-Borg Responder | Unknown | High |
Added 10 rows. Total os_results_gemini = 50
| model | operating_system | confidence | |
|---|---|---|---|
| 40 | Roche Accu-Chek Inform II | Unknown | Low |
| 41 | Rockwell Automation PanelView Plus | Windows CE | High |
| 42 | Rockwell Automation PanelView Plus 7 Performan... | Windows 10 IoT Core | High |
| 43 | Schneider Electric AS series | Linux | Medium |
| 44 | Schneider Electric AS-P | Linux | High |
| 45 | Siemens PXC Modular | Nucleus RTOS | High |
| 46 | Siemens SIMATIC HMI | Windows CE | Medium |
| 47 | Siemens SIMATIC IPC | Windows 10 Enterprise LTSC | High |
| 48 | Smiths Medical MedFusion 4000 | Unknown | Low |
| 49 | Swisslog Translogic Tube Station | Linux | High |
os_results_gemini.tail()
| model | operating_system | confidence | |
|---|---|---|---|
| 45 | Siemens PXC Modular | Nucleus RTOS | High |
| 46 | Siemens SIMATIC HMI | Windows CE | Medium |
| 47 | Siemens SIMATIC IPC | Windows 10 Enterprise LTSC | High |
| 48 | Smiths Medical MedFusion 4000 | Unknown | Low |
| 49 | Swisslog Translogic Tube Station | Linux | High |
I also saved the result table as CSV file in the working directory (content).
# Save Gemini OS table
os_results_gemini.to_csv("/content/os_results_gemini.csv", index=False)
print("Saved: /content/os_results_gemini.csv")
Saved: /content/os_results_gemini.csv
Before running the comparison, I merged the OS-identification results from ChatGPT and Gemini by device model into a single table.
I then applied a light soft matching normalization to reduce trivial naming differences (for example, vendor prefixes) while keeping meaningful OS family or version differences as real disagreements.
The comparison focuses on two aspects: coverage (whether a model returned a known OS instead of Unknown) and consistency (whether both models provided the same OS label after normalization).
Finally, I exported the full comparison table together with agreement and disagreement subsets to CSV files.
# Comparison + Export CSVs
import pandas as pd
def normalize_os_name(x: str) -> str:
"""
Light normalization ONLY for trivial naming variants.
Keeps OS family/version differences as real disagreements.
"""
if pd.isna(x):
return "unknown"
s = str(x).strip().lower()
# canonical unknown
if s in {"unknown", "unk", "n/a", "na", ""}:
return "unknown"
# trivial vendor-prefix normalizations
s = s.replace("microsoft ", "")
# APC naming variants
s = s.replace("apc os", "apc operating system")
# common abbreviation (minimal + safe)
s = s.replace("wes7", "windows embedded standard 7")
# collapse whitespace
s = " ".join(s.split())
return s
def compare_os_chatgpt_vs_gemini(save_csv=True):
# safety checks
if "os_results" not in globals():
raise ValueError("os_results (ChatGPT) not found. Run Section (b) first.")
if "os_results_gemini" not in globals() or len(os_results_gemini) == 0:
raise ValueError("os_results_gemini is empty. Paste Gemini batches first.")
# rename columns
a = os_results.rename(columns={"operating_system": "os_chatgpt", "confidence": "conf_chatgpt"}).copy()
b = os_results_gemini.rename(columns={"operating_system": "os_gemini", "confidence": "conf_gemini"}).copy()
# merge
comp = a.merge(b, on="model", how="outer")
# normalize for a "soft" comparison
comp["os_chatgpt_norm"] = comp["os_chatgpt"].apply(normalize_os_name)
comp["os_gemini_norm"] = comp["os_gemini"].apply(normalize_os_name)
# flags
comp["both_unknown"] = (comp["os_chatgpt_norm"] == "unknown") & (comp["os_gemini_norm"] == "unknown")
comp["agree_soft"] = comp["os_chatgpt_norm"] == comp["os_gemini_norm"]
comp["disagree_non_unknown"] = (~comp["agree_soft"]) & (~comp["both_unknown"])
# summary
summary = pd.DataFrame([{
"total_models": int(len(comp)),
"agree_soft": int(comp["agree_soft"].sum()),
"both_unknown": int(comp["both_unknown"].sum()),
"disagree_non_unknown": int(comp["disagree_non_unknown"].sum()),
"chatgpt_known_gemini_unknown": int(((comp["os_chatgpt_norm"] != "unknown") & (comp["os_gemini_norm"] == "unknown")).sum()),
"gemini_known_chatgpt_unknown": int(((comp["os_gemini_norm"] != "unknown") & (comp["os_chatgpt_norm"] == "unknown")).sum()),
}])
# disagreements table
disagreements = comp[comp["disagree_non_unknown"]].copy()
disagreements = disagreements[["model", "os_chatgpt", "conf_chatgpt", "os_gemini", "conf_gemini"]].sort_values("model")
# agreements where both known
agreements_known = comp[(comp["agree_soft"]) & (~comp["both_unknown"])].copy()
agreements_known = agreements_known[["model", "os_chatgpt", "conf_chatgpt", "os_gemini", "conf_gemini"]].sort_values("model")
print("=== Summary ===")
display(summary)
print("=== Disagreements (non-Unknown) ===")
display(disagreements)
if save_csv:
comp.to_csv("/content/os_compare_full.csv", index=False)
disagreements.to_csv("/content/os_compare_disagreements.csv", index=False)
agreements_known.to_csv("/content/os_compare_agreements_known.csv", index=False)
print("Saved: os_compare_full.csv, os_compare_disagreements.csv, os_compare_agreements_known.csv")
return comp, disagreements, summary, agreements_known
# run comparison + export
comp_df, disagreements_df, summary_df, agreements_known_df = compare_os_chatgpt_vs_gemini(save_csv=True)
=== Summary ===
| total_models | agree_soft | both_unknown | disagree_non_unknown | chatgpt_known_gemini_unknown | gemini_known_chatgpt_unknown | |
|---|---|---|---|---|---|---|
| 0 | 50 | 18 | 8 | 32 | 5 | 16 |
=== Disagreements (non-Unknown) ===
| model | os_chatgpt | conf_chatgpt | os_gemini | conf_gemini | |
|---|---|---|---|---|---|
| 1 | APC AP7811B | Unknown | Low | APC OS (AOS) | High |
| 4 | APC ATS | Unknown | Low | APC OS (AOS) | High |
| 7 | APC Smart-UPS 1500 | Unknown | Low | Proprietary Firmware | High |
| 8 | APC Smart-UPS SMT 1500 | Unknown | Low | Proprietary Firmware | High |
| 9 | APC Smart-UPS SRT 2200 | Unknown | Low | Proprietary Firmware | High |
| 10 | APC Smart-UPS SRT 5000 | APC Operating System (AOS) | High | APC OS (Proprietary) | High |
| 11 | Alaris 8015 PC Unit | Alaris Operating System software (v9.x) | Medium | Windows CE | High |
| 12 | Alaris 8100 Pump Module | Alaris Operating System software (v9.x) | Medium | Unknown | Low |
| 13 | Alaris 8110 Syringe Module | Alaris Operating System software (v9.x) | Medium | Unknown | Low |
| 14 | B.Braun Infusomat Space | Unknown | Low | Embedded Linux | High |
| 15 | B.Braun SmartBattery with Wifi | Unknown | Low | Embedded Linux | Medium |
| 16 | Baxter Novum IQ | Unknown | Low | Embedded Linux | High |
| 18 | Baxter Sigma Spectrum Wireless Module | Digi NET+OS | High | Unknown | Low |
| 19 | Baxter Spectrum IQ | Digi NET+OS | High | Embedded Linux | High |
| 20 | Becton Dickinson Pyxis ES System | Windows 10 IoT | High | Windows 10 IoT Enterprise | High |
| 21 | Carel PCO1000WB0 | Linux | Medium | FreeRTOS | Medium |
| 23 | Cerner Connectivity Engine | Unknown | Low | Linux | Medium |
| 25 | Hill-Rom NaviCare Room Control Board | Unknown | Low | Windows Embedded Compact | Medium |
| 27 | Hospira Plum 360 | Unknown | Low | VxWorks | High |
| 29 | Johnson Controls NAE | Windows Embedded Standard 7 (WES7) SP1 | High | Windows Embedded | High |
| 30 | Lantronix MatchPort AR | Lantronix Evolution OS | High | Evolution OS | High |
| 31 | Microsystems SPM Client | Unknown | Low | Windows | High |
| 32 | Nova Biomedical StatStrip | Windows CE | High | Unknown | High |
| 33 | Omnicell Automated Dispensing Cabinet | Windows 10 | Medium | Windows 10 IoT Enterprise | High |
| 34 | Philips EarlyVue VS30 | Unknown | Low | Linux | High |
| 35 | Philips IntelliVue | Unknown | Low | Proprietary RTOS | Medium |
| 38 | Philips PIIC | Windows Server 2012 R2 | High | Windows Server | High |
| 39 | Rauland-Borg Responder | Windows | Medium | Unknown | High |
| 42 | Rockwell Automation PanelView Plus 7 Performan... | Windows 10 IoT Core (LTSC) | High | Windows 10 IoT Core | High |
| 44 | Schneider Electric AS-P | Unknown | Low | Linux | High |
| 45 | Siemens PXC Modular | Unknown | Low | Nucleus RTOS | High |
| 47 | Siemens SIMATIC IPC | Windows | High | Windows 10 Enterprise LTSC | High |
Saved: os_compare_full.csv, os_compare_disagreements.csv, os_compare_agreements_known.csv
Now, I quantified the comparison using summary statistics: coverage rates (Known vs Unknown), soft agreement rate, and confidence-level metrics.
To test whether one model provides significantly more non-Unknown OS identifications, I used an exact McNemar test on the paired Known/Unknown outcomes (ChatGPT vs Gemini per model).
# Statistics + significance tests (ChatGPT vs Gemini)
import numpy as np
import pandas as pd
def conf_to_score(c):
if pd.isna(c):
return np.nan
c = str(c).strip().lower()
return {"low": 1, "medium": 2, "high": 3}.get(c, np.nan)
def mcnemar_exact(b, c):
"""
Exact McNemar (two-sided) using Binomial(n=b+c, p=0.5) on min(b,c).
No scipy needed.
"""
n = b + c
if n == 0:
return np.nan
k = min(b, c)
# compute 2*P(X <= k) under Bin(n, 0.5)
# using stable summation of comb * (0.5^n)
from math import comb
p_le_k = sum(comb(n, i) for i in range(k + 1)) / (2 ** n)
p_two = min(1.0, 2 * p_le_k)
return p_two
def compute_stats(comp_df: pd.DataFrame):
df = comp_df.copy()
# normalize missing
df["os_chatgpt"] = df["os_chatgpt"].fillna("Unknown")
df["os_gemini"] = df["os_gemini"].fillna("Unknown")
df["conf_chatgpt"] = df["conf_chatgpt"].fillna("")
df["conf_gemini"] = df["conf_gemini"].fillna("")
# known/unknown flags
df["chatgpt_known"] = df["os_chatgpt_norm"].ne("unknown")
df["gemini_known"] = df["os_gemini_norm"].ne("unknown")
# use soft agreement flag if exists; else define
if "agree_soft" not in df.columns:
# fall back to exact
df["agree_soft"] = (df["os_chatgpt"] == df["os_gemini"])
# core rates
total = len(df)
agree_rate = df["agree_soft"].mean()
both_unknown_rate = ((~df["chatgpt_known"]) & (~df["gemini_known"])).mean()
disagree_non_unknown_rate = ((~df["agree_soft"]) & (df["chatgpt_known"] | df["gemini_known"])).mean()
chatgpt_known_rate = df["chatgpt_known"].mean()
gemini_known_rate = df["gemini_known"].mean()
# who provides more "non-Unknown" answers?
# McNemar on paired binary outcomes: Known vs Unknown
# b = ChatGPT known & Gemini unknown
# c = ChatGPT unknown & Gemini known
b = int((df["chatgpt_known"] & ~df["gemini_known"]).sum())
c = int((~df["chatgpt_known"] & df["gemini_known"]).sum())
p_mcnemar = mcnemar_exact(b, c)
# confidence stats
df["conf_chatgpt_score"] = df["conf_chatgpt"].apply(conf_to_score)
df["conf_gemini_score"] = df["conf_gemini"].apply(conf_to_score)
# Avg confidence where OS is known
avg_conf_chatgpt_known = df.loc[df["chatgpt_known"], "conf_chatgpt_score"].mean()
avg_conf_gemini_known = df.loc[df["gemini_known"], "conf_gemini_score"].mean()
# High-confidence coverage (among all models)
chatgpt_high_rate = (df["conf_chatgpt"].str.strip().str.lower() == "high").mean()
gemini_high_rate = (df["conf_gemini"].str.strip().str.lower() == "high").mean()
# agreement breakdown
# Agreement only among cases where BOTH give a known OS
both_known = df[df["chatgpt_known"] & df["gemini_known"]].copy()
agree_among_both_known = both_known["agree_soft"].mean() if len(both_known) else np.nan
# output tables
stats = pd.DataFrame([{
"total_models": total,
"agreement_rate_soft": round(agree_rate * 100, 1),
"both_unknown_rate": round(both_unknown_rate * 100, 1),
"disagree_non_unknown_rate": round(disagree_non_unknown_rate * 100, 1),
"chatgpt_known_rate": round(chatgpt_known_rate * 100, 1),
"gemini_known_rate": round(gemini_known_rate * 100, 1),
"agreement_rate_among_both_known": (round(agree_among_both_known * 100, 1) if not np.isnan(agree_among_both_known) else np.nan),
"avg_conf_chatgpt_when_known": round(avg_conf_chatgpt_known, 2),
"avg_conf_gemini_when_known": round(avg_conf_gemini_known, 2),
"high_conf_rate_chatgpt": round(chatgpt_high_rate * 100, 1),
"high_conf_rate_gemini": round(gemini_high_rate * 100, 1),
"mcnemar_b_chatgpt_known_gemini_unknown": b,
"mcnemar_c_chatgpt_unknown_gemini_known": c,
"mcnemar_p_value_two_sided": p_mcnemar
}])
# A small “who wins where” table
coverage_tbl = pd.DataFrame({
"case": [
"Both Known",
"Both Unknown",
"ChatGPT Known, Gemini Unknown",
"Gemini Known, ChatGPT Unknown"
],
"count": [
int((df["chatgpt_known"] & df["gemini_known"]).sum()),
int((~df["chatgpt_known"] & ~df["gemini_known"]).sum()),
b,
c
]
})
coverage_tbl["percent"] = (coverage_tbl["count"] / total * 100).round(1)
print("=== Key statistics (percentages + confidence) ===")
display(stats)
print("=== Coverage breakdown ===")
display(coverage_tbl)
# show top disagreements where one model is High confidence
df_dis = df[(~df["agree_soft"]) & (df["chatgpt_known"] | df["gemini_known"])].copy()
df_dis["chatgpt_high"] = df["conf_chatgpt"].str.lower().eq("high")
df_dis["gemini_high"] = df["conf_gemini"].str.lower().eq("high")
interesting = df_dis[(df_dis["chatgpt_high"] | df_dis["gemini_high"])][
["model","os_chatgpt","conf_chatgpt","os_gemini","conf_gemini"]
].sort_values("model")
print("=== Disagreements where at least one model is High confidence ===")
display(interesting)
return stats, coverage_tbl, interesting
stats_df, coverage_df, interesting_disagreements_df = compute_stats(comp_df)
=== Key statistics (percentages + confidence) ===
| total_models | agreement_rate_soft | both_unknown_rate | disagree_non_unknown_rate | chatgpt_known_rate | gemini_known_rate | agreement_rate_among_both_known | avg_conf_chatgpt_when_known | avg_conf_gemini_when_known | high_conf_rate_chatgpt | high_conf_rate_gemini | mcnemar_b_chatgpt_known_gemini_unknown | mcnemar_c_chatgpt_unknown_gemini_known | mcnemar_p_value_two_sided | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 50 | 36.0 | 16.0 | 64.0 | 52.0 | 74.0 | 47.6 | 2.65 | 2.76 | 34.0 | 64.0 | 5 | 16 | 0.026604 |
=== Coverage breakdown ===
| case | count | percent | |
|---|---|---|---|
| 0 | Both Known | 21 | 42.0 |
| 1 | Both Unknown | 8 | 16.0 |
| 2 | ChatGPT Known, Gemini Unknown | 5 | 10.0 |
| 3 | Gemini Known, ChatGPT Unknown | 16 | 32.0 |
=== Disagreements where at least one model is High confidence ===
| model | os_chatgpt | conf_chatgpt | os_gemini | conf_gemini | |
|---|---|---|---|---|---|
| 1 | APC AP7811B | Unknown | Low | APC OS (AOS) | High |
| 4 | APC ATS | Unknown | Low | APC OS (AOS) | High |
| 7 | APC Smart-UPS 1500 | Unknown | Low | Proprietary Firmware | High |
| 8 | APC Smart-UPS SMT 1500 | Unknown | Low | Proprietary Firmware | High |
| 9 | APC Smart-UPS SRT 2200 | Unknown | Low | Proprietary Firmware | High |
| 10 | APC Smart-UPS SRT 5000 | APC Operating System (AOS) | High | APC OS (Proprietary) | High |
| 11 | Alaris 8015 PC Unit | Alaris Operating System software (v9.x) | Medium | Windows CE | High |
| 14 | B.Braun Infusomat Space | Unknown | Low | Embedded Linux | High |
| 16 | Baxter Novum IQ | Unknown | Low | Embedded Linux | High |
| 18 | Baxter Sigma Spectrum Wireless Module | Digi NET+OS | High | Unknown | Low |
| 19 | Baxter Spectrum IQ | Digi NET+OS | High | Embedded Linux | High |
| 20 | Becton Dickinson Pyxis ES System | Windows 10 IoT | High | Windows 10 IoT Enterprise | High |
| 27 | Hospira Plum 360 | Unknown | Low | VxWorks | High |
| 29 | Johnson Controls NAE | Windows Embedded Standard 7 (WES7) SP1 | High | Windows Embedded | High |
| 30 | Lantronix MatchPort AR | Lantronix Evolution OS | High | Evolution OS | High |
| 31 | Microsystems SPM Client | Unknown | Low | Windows | High |
| 32 | Nova Biomedical StatStrip | Windows CE | High | Unknown | High |
| 33 | Omnicell Automated Dispensing Cabinet | Windows 10 | Medium | Windows 10 IoT Enterprise | High |
| 34 | Philips EarlyVue VS30 | Unknown | Low | Linux | High |
| 38 | Philips PIIC | Windows Server 2012 R2 | High | Windows Server | High |
| 39 | Rauland-Borg Responder | Windows | Medium | Unknown | High |
| 42 | Rockwell Automation PanelView Plus 7 Performan... | Windows 10 IoT Core (LTSC) | High | Windows 10 IoT Core | High |
| 44 | Schneider Electric AS-P | Unknown | Low | Linux | High |
| 45 | Siemens PXC Modular | Unknown | Low | Nucleus RTOS | High |
| 47 | Siemens SIMATIC IPC | Windows | High | Windows 10 Enterprise LTSC | High |
I visualized the coverage breakdown as percentages: cases where both models identified an OS, both returned Unknown, and cases where only one model identified the OS.
This makes it easy to compare which model provides more usable OS labels.
def plot_coverage_breakdown(coverage_df):
plt.figure(figsize=(7,4))
plt.bar(coverage_df["case"], coverage_df["percent"])
plt.ylabel("Percent (%)")
plt.title("Coverage Breakdown: ChatGPT vs Gemini")
plt.xticks(rotation=25)
plt.tight_layout()
plt.show()
plot_coverage_breakdown(coverage_df)
Interpretation of Results¶
The coverage breakdown graph summarizes how often each model returned a known operating system label versus Unknown across the 50 evaluated devices.
As shown in the figure, the largest group is Both Known (42%), followed by cases where Gemini provided a known OS while ChatGPT returned Unknown (32%). In contrast, only 10% of devices show the opposite pattern (ChatGPT Known, Gemini Unknown), while 16% of devices were marked Unknown by both models.
Overall, Gemini provided OS identifications more often than ChatGPT: Gemini returned a known OS for 74% of models, compared to 52% for ChatGPT.
This asymmetry is statistically significant based on an exact McNemar test (p = 0.0266), indicating that Gemini has higher coverage in this task (i.e., it produces fewer Unknown results).
When both models returned a known OS (42% of models), the soft agreement rate was 47.6%, suggesting that even when both provide an answer, they often disagree on the specific OS label.
Gemini also showed slightly higher average confidence on known OS predictions (2.76 vs 2.65 on a 1–3 scale).
Conclusion: the graph and the statistical results suggest that Gemini provides broader coverage, meaning it returns known OS labels for more devices. At the same time, the agreement between the two models is only moderate, indicating that even when both models identify an operating system, they do not always assign the same OS label.
Task 3 – SQL (Question 1)¶
Here, I load the devices.csv file into a SQLite table and query it using SQL.
# Load devices.csv
import pandas as pd
import sqlite3
# load csv from content
devices = pd.read_csv("/content/devices.csv")
# create sqlite database in memory
conn = sqlite3.connect(":memory:")
# write table
devices.to_sql("devices", conn, index=False, if_exists="replace")
print("Table loaded successfully")
# quick data preview
display(devices.head())
print("Shape:", devices.shape)
print("Columns:", list(devices.columns))
Table loaded successfully
| connection_type | ip | mac | device_id | category | sub_category | manufacturer | type | model | serial_number | os | sw_fw_version | hw_version | vlan | connector_device_id | ap_name | ap_location | risk_score_points | risk_score | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | Ethernet Connection | 10.5.29.222 | 0A:65:BC:EB:F0:9E | CQPBBPDHSD | Medical | Imaging | GE | PACS | Centricity Enterprise Archive | NaN | NaN | 4.120.10324.12154 | NaN | 205 | NaN | NaN | NaN | 47 | Medium |
| 1 | Ethernet Connection | 10.5.29.221 | 0A:65:BC:FC:A5:0B | CQPBCZSDMX | Medical | Imaging | GE | PACS | Centricity Enterprise Archive | NaN | NaN | 4.120.10324.12154 | NaN | 205 | NaN | NaN | NaN | 49 | Medium |
| 2 | Ethernet Connection | 10.5.29.220 | 0A:65:BC:00:4E:58 | CQPBEKGZHR | Medical | Imaging | GE | PACS | Centricity Enterprise Archive | NaN | NaN | 4.120.10324.12154 | NaN | 205 | NaN | NaN | NaN | 56 | Medium |
| 3 | Ethernet Connection | 10.5.29.245 | 0A:65:BC:08:FB:B0 | CQPBMYINOH | Medical | Patient Devices | Welch Allyn | Vital Signs Monitor Gateway | Connex | KQTACC2E | Windows | NaN | NaN | 205 | NaN | NaN | NaN | 39 | Very Low |
| 4 | Ethernet Connection | 10.5.29.219 | 0A:65:BC:CF:FA:FE | CQPBRBKKLV | Medical | Imaging | GE | PACS | Centricity Enterprise Archive | NaN | NaN | 4.120.10324.12154 | NaN | 205 | NaN | NaN | NaN | 73 | Critical |
Shape: (4987, 19) Columns: ['connection_type', 'ip', 'mac', 'device_id', 'category', 'sub_category', 'manufacturer', 'type', 'model', 'serial_number', 'os', 'sw_fw_version', 'hw_version', 'vlan', 'connector_device_id', 'ap_name', 'ap_location', 'risk_score_points', 'risk_score']
The goal of this query is to list all software / firmware versions available for each Infusion Pump Module model.
query_q1 = """
SELECT
model,
sw_fw_version,
COUNT(*) AS device_count
FROM devices
WHERE type = 'Infusion Pump Module'
AND sw_fw_version IS NOT NULL
AND TRIM(sw_fw_version) != ''
GROUP BY model, sw_fw_version
ORDER BY model, device_count DESC, sw_fw_version;
"""
q1_results = pd.read_sql_query(query_q1, conn)
q1_results
| model | sw_fw_version | device_count | |
|---|---|---|---|
| 0 | 8100 Pump Module | 9.17.0.22 | 217 |
| 1 | 8110 Syringe Module | 9.33.0.50 | 191 |
| 2 | 8110 Syringe Module | 9.15.1.2 | 36 |
| 3 | 8300 EtCO2 Module | 8.4.5.6 | 153 |
| 4 | 8300 EtCO2 Module | 8.4.5.0 | 42 |
Interpretation¶
For some models (such as the 8110 Syringe Module and the 8300 EtCO2 Module), more than one software version exists, which indicates that devices of the same model are not fully standardized.
The version with the higher device count is likely the main or newer deployment, while versions with fewer devices may represent older or less common installations.
Now, I identify which Infusion Pump Module devices may run obsolete software versions.
I treat older versions as those that are not the most common (latest deployed) version within each model.
query_obsolete = """
WITH version_counts AS (
SELECT
model,
sw_fw_version,
COUNT(*) AS device_count
FROM devices
WHERE type = 'Infusion Pump Module'
GROUP BY model, sw_fw_version
),
ranked_versions AS (
SELECT
model,
sw_fw_version,
device_count,
RANK() OVER (
PARTITION BY model
ORDER BY device_count DESC
) AS version_rank
FROM version_counts
)
SELECT
model,
sw_fw_version,
device_count
FROM ranked_versions
WHERE version_rank > 1
ORDER BY model, device_count DESC;
"""
obsolete_results = pd.read_sql_query(query_obsolete, conn)
obsolete_results
| model | sw_fw_version | device_count | |
|---|---|---|---|
| 0 | 8110 Syringe Module | 9.15.1.2 | 36 |
| 1 | 8300 EtCO2 Module | 8.4.5.0 | 42 |
Interpretation¶
Based on the query results, two firmware versions appear with relatively low counts compared to other versions of the same models:
- 8110 Syringe Module – version 9.15.1.2: 36 devices
- 8300 EtCO2 Module – version 8.4.5.0: 42 devices
These versions appear much less frequently than alternative versions for the same models, which suggests they may be older installations.
Answer to the Question¶
Based on the information in the table, the common pattern between these devices is that they all represent less common software versions compared to other versions of the same model.
Each of these versions appears on a relatively small number of devices, which suggests they may be older deployments or devices that were not updated together with the majority of the fleet.
In other words, these devices stand out because they run software versions that are less frequently used within their device model, making them potential legacy or outdated instances.
VLAN Analysis – IoT vs IT Ratio¶
Now I am analyzing the VLAN structure in order to compare the number of IoT devices versus IT devices in each VLAN.
My goal is to calculate the IoT-to-IT ratio per VLAN, so I can later identify which VLAN has the highest ratio and understand its possible purpose.
# SQL query – VLAN IoT vs IT ratio
query_vlan_ratio = """
SELECT
vlan,
SUM(CASE WHEN category = 'IoT' THEN 1 ELSE 0 END) AS iot_count,
SUM(CASE WHEN category = 'IT' THEN 1 ELSE 0 END) AS it_count,
ROUND(
CAST(SUM(CASE WHEN category = 'IoT' THEN 1 ELSE 0 END) AS FLOAT) /
NULLIF(SUM(CASE WHEN category = 'IT' THEN 1 ELSE 0 END), 0),
2
) AS iot_to_it_ratio
FROM devices
GROUP BY vlan
ORDER BY iot_to_it_ratio DESC;
"""
vlan_ratio_results = pd.read_sql_query(query_vlan_ratio, conn)
vlan_ratio_results
| vlan | iot_count | it_count | iot_to_it_ratio | |
|---|---|---|---|---|
| 0 | 970 | 2156 | 10 | 215.60 |
| 1 | 307 | 48 | 13 | 3.69 |
| 2 | 305 | 54 | 15 | 3.60 |
| 3 | 309 | 56 | 18 | 3.11 |
| 4 | 306 | 62 | 20 | 3.10 |
| ... | ... | ... | ... | ... |
| 57 | 225 | 0 | 0 | NaN |
| 58 | 221 | 0 | 0 | NaN |
| 59 | 212 | 0 | 0 | NaN |
| 60 | 211 | 0 | 0 | NaN |
| 61 | 81 | 0 | 0 | NaN |
62 rows × 4 columns
Interpretation of the Results¶
When I look at the IoT-to-IT ratios, VLAN 970 clearly stands out.
It contains 2156 IoT devices and only 10 IT devices, giving an extremely high ratio (about 215.6).
From this distribution, I understand that the purpose of this VLAN is probably to separate and group IoT devices in a dedicated network segment. Most of the devices in this VLAN are IoT devices, which suggests it is mainly used for operational or medical equipment rather than regular IT infrastructure.
Because the VLAN is almost entirely IoT-based, the small number of IT devices seems unusual. My interpretation is that these IT devices probably do not belong in this VLAN and would be better placed in a standard IT VLAN.