Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
ef3c0c0
Intro notebooks
May 21, 2026
021bb7d
Add CLAUDE.md
May 21, 2026
4e9bdd1
Initial DPBuiltinCPInstruction and RDPAccountant
Jun 26, 2026
19c2504
Add dp_gaussian and dp_laplace operations
Jun 26, 2026
7dc493c
CLAUDE_CODE_PLAN.md
Jul 2, 2026
bd7a956
Fix test package name.
Jul 2, 2026
5d31fda
Fix Laplace accountant
Jul 6, 2026
4b58bc4
Add unit tests
Jul 6, 2026
2936970
Rename RDPAccountant to DPBudgetAccountant
Jul 6, 2026
c6b7c82
Delete RDPAccountant
Jul 6, 2026
ef4680f
Fix parseInstruction() comment
Jul 6, 2026
1d0fac3
Integration tests
Jul 6, 2026
ae2afcb
Update test comments
Jul 6, 2026
394c021
Fix integration test to compare clean to noisy data
Jul 7, 2026
67fe1b2
Make integration test more readable
Jul 7, 2026
fbe18f0
Comment fix
Jul 7, 2026
f22c283
Fix L1 L2 sensitivity parameter documentation
Jul 7, 2026
461da60
junit output format
Jul 7, 2026
b253d14
Remove Claude files
Jul 7, 2026
379ddcd
Remove files irrelevant to the PR
Jul 9, 2026
4159947
dp_laplace and dp_gaussian now take the original matrix and build a t…
Jul 9, 2026
d8f0f45
Remove 4th and 5th parameters and instead get them in a loop, accordi…
Jul 13, 2026
124ca53
Add dp_set_budget(epsilon, delta) built-in
Jul 14, 2026
025f9bb
Change DPBudgetAccountant defaults to be constants
Jul 14, 2026
f29cd3e
Cleanup comments
Jul 14, 2026
d8912b1
Differential Privacy Benchmark
Jul 14, 2026
c517af2
Checkstyle fix
Jul 21, 2026
ae8a136
Add missing licenses
Jul 21, 2026
4db9f40
More checkstyle fixes
Jul 22, 2026
19f6fd0
Add missing break. Fixes test
Jul 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions benchmark/scripts/collect_results.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
#-------------------------------------------------------------
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#
#-------------------------------------------------------------

"""
Parse per-run accuracy files into a single results.csv.

Output columns: label, epsilon, private, accuracy
"""
import pathlib, csv, re

RESULTS = pathlib.Path("benchmark/results")

rows = []

def parse_acc(path: pathlib.Path) -> float:
txt = path.read_text().strip()
# SystemDS writes a bare float.
return float(txt)

# Non-private baseline.
baseline_path = RESULTS / "acc_baseline.txt"
if baseline_path.exists():
rows.append(dict(label="baseline", epsilon="inf",
private=0, accuracy=parse_acc(baseline_path)))

# DP runs.
for eps in [0.5, 1, 4, 8]:
p = RESULTS / f"acc_eps_{eps}.txt"
if p.exists():
rows.append(dict(label=f"ε={eps}", epsilon=eps,
private=1, accuracy=parse_acc(p)))
else:
print(f"Warning: {p} not found — skipping")

out = RESULTS / "results.csv"
with open(out, "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=["label","epsilon","private","accuracy"])
w.writeheader()
w.writerows(rows)

print(f"Wrote {out}")
for r in rows:
print(f" {r['label']:12s} acc={r['accuracy']:.4f}")

43 changes: 43 additions & 0 deletions benchmark/scripts/eval.dml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#-------------------------------------------------------------
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#
#-------------------------------------------------------------

# eval.dml — compute binary classification accuracy on held-out test set.
# Arguments: data_dir, model_path, out_acc
data_dir = $data_dir;
model_path = $model_path;
out_acc = $out_acc;

X_test = read(data_dir + "/X_test.csv",
data_type="matrix", value_type="double", format="csv");
y_test = read(data_dir + "/y_test.csv",
data_type="matrix", value_type="double", format="csv");
w = read(model_path,
data_type="matrix", value_type="double", format="csv");

scores = X_test %*% w;
preds = (scores > 0.0); # threshold at 0 (log-odds)
correct = sum(preds == y_test);
n_test = nrow(y_test);
accuracy = correct / n_test;

print("Accuracy: " + accuracy);
write(accuracy, out_acc, format="csv");

151 changes: 151 additions & 0 deletions benchmark/scripts/fedavg_dp.dml
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
#-------------------------------------------------------------
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#
#-------------------------------------------------------------

# ── fedavg_dp.dml ────────────────────────────────────────────────────────────
# Arguments (passed via -nvargs):
# data_dir : path to benchmark/data/
# epsilon : DP privacy budget ε (use 9999 for non-private baseline)
# delta : DP delta (1e-5 can be used)
# clip_norm : per-example gradient L2-norm clip bound (default 4.0) —
# sensitivity = clip_norm / n follows from this, since
# clipping each record's gradient contribution to clip_norm
# is what makes that bound actually hold.
# n_rounds : number of FedAvg rounds (default 50)
# lr : learning rate (default 0.1)
# w1_rows : rows in worker 1 shard
# w2_rows : rows in worker 2 shard
# w3_rows : rows in worker 3 shard
# w4_rows : rows in worker 4 shard
# n_features : number of features
# out : path to write final weights
# private : 1 = apply DP noise (default), 0 = non-private baseline

data_dir = $data_dir;
epsilon = $epsilon;
delta = $delta;
clip_norm = ifdef($clip_norm, 4.0);
n_rounds = ifdef($n_rounds, 50);
lr = ifdef($lr, 0.1);
private = ifdef($private, 1);
out_path = $out;

# Per-round epsilon: dp_gaussian is called once per round, and
# DPBudgetAccountant composes the cost of every release against the single
# total budget set by dp_set_budget(). Spending the full epsilon on every
# round would exhaust the budget almost immediately, so split it evenly
# across rounds instead.
round_epsilon = epsilon / n_rounds;

w1r = $w1_rows;
w2r = $w2_rows;
w3r = $w3_rows;
w4r = $w4_rows;
d = $n_features;
n = w1r + w2r + w3r + w4r;

# Sensitivity of the released (mean) gradient to a single record changing:
# with each record's contribution clipped to L2-norm <= clip_norm below,
# the sum can move by at most clip_norm, so the average moves by clip_norm/n.
sensitivity = clip_norm / n;

# ── Build federated matrix from 4 local workers ───────────────────────────
# Row ranges are 0-based [begin, end) for each partition.
r1s=0; r1e=w1r;
r2s=w1r; r2e=w1r+w2r;
r3s=w1r+w2r; r3e=w1r+w2r+w3r;
r4s=w1r+w2r+w3r; r4e=n;

X = federated(
addresses=list(
"localhost:8301/" + data_dir + "/worker1/X_train.csv",
"localhost:8302/" + data_dir + "/worker2/X_train.csv",
"localhost:8303/" + data_dir + "/worker3/X_train.csv",
"localhost:8304/" + data_dir + "/worker4/X_train.csv"),
ranges=list(
list(r1s, 0), list(r1e, d),
list(r2s, 0), list(r2e, d),
list(r3s, 0), list(r3e, d),
list(r4s, 0), list(r4e, d)));

y = federated(
addresses=list(
"localhost:8301/" + data_dir + "/worker1/y_train.csv",
"localhost:8302/" + data_dir + "/worker2/y_train.csv",
"localhost:8303/" + data_dir + "/worker3/y_train.csv",
"localhost:8304/" + data_dir + "/worker4/y_train.csv"),
ranges=list(
list(r1s, 0), list(r1e, 1),
list(r2s, 0), list(r2e, 1),
list(r3s, 0), list(r3e, 1),
list(r4s, 0), list(r4e, 1)));

# ── Initialise weights ────────────────────────────────────────────────────
w = matrix(0, rows=d, cols=1);

if (private == 1) {
eps = dp_set_budget($epsilon, $delta);
}

# ── Training rounds ───────────────────────────────────────────────────────
for (round in 1:n_rounds) {

# Forward pass — executes on federated workers.
scores = X %*% w; # (n × 1), federated
probs = 1.0 / (1.0 + exp(-scores)); # (n × 1), federated

residuals = probs - y; # (n × 1), federated

# DP noise injection — only when private=1.
if (private == 1) {
# Per-example L2-norm clipping: each record's raw gradient
# contribution is X[i,:] * residual_i, with norm
# ||X[i,:]||_2 * |residual_i|. Scaling it down to clip_norm
# whenever it exceeds that bound is what makes
# sensitivity = clip_norm / n (set above) an actual, provable
# bound instead of an arbitrary constant.
row_norms = sqrt(rowSums(X^2)); # (n × 1) ||X[i,:]||_2
contrib_norms = abs(residuals) * row_norms; # (n × 1) ||X[i,:]*residual_i||_2
clip_scale = clip_norm / pmax(contrib_norms, clip_norm); # (n × 1), in (0,1]
clipped_residuals = residuals * clip_scale; # (n × 1)

# Gradient aggregation — t(X) %*% clipped_residuals is a (d × 1)
# local sum that the coordinator collects in one federated
# aggregate instruction.
grad = t(X) %*% clipped_residuals / n; # (d × 1), LOCAL after agg, clipped

noisy_grad = dp_gaussian(grad,
"identity",
sensitivity=sensitivity,
epsilon=round_epsilon,
delta=delta);
w = w - lr * noisy_grad;
} else {
# Gradient aggregation — t(X) %*% residuals is an (d × 1) local sum
# that the coordinator collects in one federated aggregate instruction.
grad = t(X) %*% residuals / n; # (d × 1), LOCAL after agg
w = w - lr * grad;
}
}

# ── Write model weights ───────────────────────────────────────────────────
write(w, out_path, format="csv");
print("FedAvg done. epsilon=" + epsilon + " rounds=" + n_rounds);

134 changes: 134 additions & 0 deletions benchmark/scripts/plot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
#-------------------------------------------------------------
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#
#-------------------------------------------------------------
"""
Read results.csv and produce two figures:

1. accuracy_vs_epsilon.png
Line plot: x = ε, y = accuracy.
Horizontal dashed line = non-private baseline.
Points labelled with accuracy values.

2. privacy_cost.png
Bar chart showing accuracy loss relative to baseline (utility cost of DP).
"""
import pathlib
import csv
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import matplotlib.ticker as ticker

RESULTS = pathlib.Path("benchmark/results")

# ── Load ──────────────────────────────────────────────────────────────────
rows = []
with open(RESULTS / "results.csv") as f:
for r in csv.DictReader(f):
rows.append({
"label": r["label"],
"epsilon": float(r["epsilon"]) if r["epsilon"] != "inf" else None,
"private": int(r["private"]),
"accuracy": float(r["accuracy"]),
})

baseline = next(r for r in rows if r["private"] == 0)
dp_rows = sorted([r for r in rows if r["private"] == 1],
key=lambda r: r["epsilon"])

eps_vals = [r["epsilon"] for r in dp_rows]
acc_vals = [r["accuracy"] for r in dp_rows]
baseline_acc = baseline["accuracy"]

# ── Figure 1: Accuracy vs ε ───────────────────────────────────────────────
fig, ax = plt.subplots(figsize=(7, 4.5))

ax.plot(eps_vals, acc_vals, marker="o", linewidth=2,
color="#028090", label="DP-FedAvg (Gaussian)")
ax.axhline(baseline_acc, linestyle="--", color="#1C3A5E",
linewidth=1.5, label=f"Non-private baseline ({baseline_acc:.3f})")

# Annotate each DP point.
for eps, acc in zip(eps_vals, acc_vals):
ax.annotate(f"{acc:.3f}", xy=(eps, acc),
xytext=(0, 8), textcoords="offset points",
ha="center", fontsize=9, color="#028090")

ax.set_xscale("log")
ax.set_xticks(eps_vals)
ax.get_xaxis().set_major_formatter(ticker.ScalarFormatter())
ax.set_xlabel("Privacy budget ε (smaller = stronger privacy)", fontsize=11)
ax.set_ylabel("Test accuracy", fontsize=11)
ax.set_title("Accuracy vs. Privacy Budget — DP-FedAvg on Adult (4 workers)",
fontsize=12)
ax.legend(fontsize=9)
ax.set_ylim(max(0, min(acc_vals) - 0.05), min(1.0, baseline_acc + 0.05))
ax.grid(True, which="both", linestyle=":", alpha=0.5)

plt.tight_layout()
out1 = RESULTS / "accuracy_vs_epsilon.png"
fig.savefig(out1, dpi=150)
print(f"Saved {out1}")
plt.close()

# ── Figure 2: Utility cost (accuracy drop) ────────────────────────────────
fig, ax = plt.subplots(figsize=(6, 4))

drops = [baseline_acc - acc for acc in acc_vals]
colors = ["#B91C1C" if d > 0.02 else "#028090" for d in drops]
# Position bars at their true ε value on a log-scaled x-axis (rather than
# evenly-spaced categorical slots) so the visual spacing between 0.5→1 and
# 4→8 reflects the same 2x ratio. Bar widths scale with x so they stay a
# constant fraction of their slot in log space instead of shrinking/growing.
widths = [e * 0.4 for e in eps_vals]
bars = ax.bar(eps_vals, drops, color=colors, width=widths,
edgecolor="white")
ax.set_xscale("log")
ax.set_xticks(eps_vals)
ax.get_xaxis().set_major_formatter(ticker.ScalarFormatter())

for bar, drop in zip(bars, drops):
ax.text(bar.get_x() + bar.get_width() / 2,
bar.get_height() + 0.001,
f"{drop:.3f}", ha="center", va="bottom", fontsize=9)

ax.legend(["drop > 0.02", "drop <= 0.02"])

ax.axhline(0, color="black", linewidth=0.8)
ax.set_xlabel("Privacy budget ε", fontsize=11)
ax.set_ylabel("Accuracy drop vs. baseline", fontsize=11)
ax.set_title("Utility Cost of Differential Privacy — DP-FedAvg on Adult",
fontsize=11)
ax.grid(True, axis="y", linestyle=":", alpha=0.5)

plt.tight_layout()
out2 = RESULTS / "privacy_cost.png"
fig.savefig(out2, dpi=150)
print(f"Saved {out2}")
plt.close()

# ── Console summary table ─────────────────────────────────────────────────
print()
print(f"{'ε':>8} {'accuracy':>10} {'drop':>8}")
print("-" * 34)
print(f"{'baseline':>8} {baseline_acc:10.4f} {'—':>8}")
for eps, acc, drop in zip(eps_vals, acc_vals, drops):
print(f"{eps:>8.1f} {acc:10.4f} {drop:8.4f}")

Loading
Loading