diff --git a/benchmark/scripts/collect_results.py b/benchmark/scripts/collect_results.py new file mode 100644 index 00000000000..c15ce63aa84 --- /dev/null +++ b/benchmark/scripts/collect_results.py @@ -0,0 +1,41 @@ +""" +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}") + diff --git a/benchmark/scripts/eval.dml b/benchmark/scripts/eval.dml new file mode 100644 index 00000000000..7f0c89fbaa4 --- /dev/null +++ b/benchmark/scripts/eval.dml @@ -0,0 +1,22 @@ +# 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"); + diff --git a/benchmark/scripts/fedavg_dp.dml b/benchmark/scripts/fedavg_dp.dml new file mode 100644 index 00000000000..d4722fdb2a6 --- /dev/null +++ b/benchmark/scripts/fedavg_dp.dml @@ -0,0 +1,130 @@ +# ── 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); + diff --git a/benchmark/scripts/plot.py b/benchmark/scripts/plot.py new file mode 100644 index 00000000000..ac0a82850e3 --- /dev/null +++ b/benchmark/scripts/plot.py @@ -0,0 +1,114 @@ +""" +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}") + diff --git a/benchmark/scripts/prepare_data.py b/benchmark/scripts/prepare_data.py new file mode 100644 index 00000000000..dce8e8e8006 --- /dev/null +++ b/benchmark/scripts/prepare_data.py @@ -0,0 +1,123 @@ +""" +Download the UCI Adult dataset, binarise labels, standardise features, +split into 4 equal horizontal partitions for federated workers, and write +SystemDS .mtd metadata files alongside each CSV shard. + +Outputs +------- +benchmark/data/worker{1..4}/X_train.csv + X_train.csv.mtd +benchmark/data/worker{1..4}/y_train.csv + y_train.csv.mtd +benchmark/data/X_test.csv + X_test.csv.mtd +benchmark/data/y_test.csv + y_test.csv.mtd +benchmark/data/meta.txt # n_train, n_test, n_features +""" + +import json, os, pathlib +import numpy as np +import pandas as pd +from sklearn.model_selection import train_test_split +from sklearn.preprocessing import StandardScaler + +ADULT_TRAIN_URL = ( + "https://archive.ics.uci.edu/ml/machine-learning-databases" + "/adult/adult.data" +) +ADULT_TEST_URL = ( + "https://archive.ics.uci.edu/ml/machine-learning-databases" + "/adult/adult.test" +) + +COLS = [ + "age","workclass","fnlwgt","education","education_num","marital_status", + "occupation","relationship","race","sex","capital_gain","capital_loss", + "hours_per_week","native_country","label", +] +NUMERIC = ["age","fnlwgt","education_num","capital_gain", + "capital_loss","hours_per_week"] + +DATA_DIR = pathlib.Path("benchmark/data") +N_WORKERS = 4 + +def download(url, dest): + import urllib.request + if not dest.exists(): + print(f"Downloading {url}") + urllib.request.urlretrieve(url, dest) + +def load_adult(path, skip_rows=0): + df = pd.read_csv(path, names=COLS, skipinitialspace=True, + skiprows=skip_rows, na_values="?").dropna() + # binarise label: >50K → 1, else 0 + df["label"] = (df["label"].str.strip().str.rstrip(".") == ">50K").astype(float) + # one-hot encode categoricals + cats = [c for c in COLS[:-1] if c not in NUMERIC] + df = pd.get_dummies(df, columns=cats, drop_first=True) + return df + +def write_csv_and_mtd(arr: np.ndarray, path: pathlib.Path, description: str): + """Write a CSV and a matching SystemDS .mtd metadata file.""" + path.parent.mkdir(parents=True, exist_ok=True) + np.savetxt(path, arr, delimiter=",", fmt="%.8f") + rows, cols = arr.shape + mtd = { + "data_type": "matrix", + "value_type": "double", + "rows": rows, + "cols": cols, + "format": "csv", + "header": False, + "description": description, + } + with open(str(path) + ".mtd", "w") as f: + json.dump(mtd, f, indent=2) + print(f" {path} ({rows} × {cols})") + +# ── Download ───────────────────────────────────────────────────────────────── +download(ADULT_TRAIN_URL, DATA_DIR / "raw" / "adult.data") +download(ADULT_TEST_URL, DATA_DIR / "raw" / "adult.test") + +train_df = load_adult(DATA_DIR / "raw" / "adult.data") +test_df = load_adult(DATA_DIR / "raw" / "adult.test", skip_rows=1) + +# Align columns (test may have different dummies after get_dummies). +train_df, test_df = train_df.align(test_df, join="left", axis=1, fill_value=0) + +# ── Feature / label split ──────────────────────────────────────────────────── +feature_cols = [c for c in train_df.columns if c != "label"] +X_train = train_df[feature_cols].values.astype(float) +y_train = train_df["label"].values.reshape(-1, 1).astype(float) +X_test = test_df[feature_cols].values.astype(float) +y_test = test_df["label"].values.reshape(-1, 1).astype(float) + +# ── Standardise (fit on train only) ───────────────────────────────────────── +scaler = StandardScaler() +X_train = scaler.fit_transform(X_train) +X_test = scaler.transform(X_test) + +n_train, n_features = X_train.shape +n_test = X_test.shape[0] +print(f"Train: {n_train} × {n_features} | Test: {n_test} × {n_features}") + +# ── Partition across workers (equal horizontal splits) ────────────────────── +indices = np.array_split(np.arange(n_train), N_WORKERS) +for i, idx in enumerate(indices, start=1): + wdir = DATA_DIR / f"worker{i}" + write_csv_and_mtd(X_train[idx], wdir / "X_train.csv", + f"Adult features, worker {i}") + write_csv_and_mtd(y_train[idx], wdir / "y_train.csv", + f"Adult labels, worker {i}") + +# ── Test set (coordinator-local) ──────────────────────────────────────────── +write_csv_and_mtd(X_test, DATA_DIR / "X_test.csv", "Adult test features") +write_csv_and_mtd(y_test, DATA_DIR / "y_test.csv", "Adult test labels") + +# ── Metadata for DML scripts ───────────────────────────────────────────────── +worker_rows = [len(idx) for idx in indices] +with open(DATA_DIR / "meta.txt", "w") as f: + f.write(f"n_train={n_train}\n") + f.write(f"n_test={n_test}\n") + f.write(f"n_features={n_features}\n") + for i, r in enumerate(worker_rows, start=1): + f.write(f"worker{i}_rows={r}\n") +print("Wrote benchmark/data/meta.txt") + diff --git a/benchmark/scripts/run_benchmark.sh b/benchmark/scripts/run_benchmark.sh new file mode 100755 index 00000000000..70966fa30ad --- /dev/null +++ b/benchmark/scripts/run_benchmark.sh @@ -0,0 +1,15 @@ +# 1. Prepare data (once). +python benchmark/scripts/prepare_data.py + +# 2. Run the sweep (starts workers, trains, evaluates, stops workers). +bash benchmark/scripts/run_sweep.sh + +# 3. Collect results into CSV. +python benchmark/scripts/collect_results.py + +# 4. Generate plots. +python benchmark/scripts/plot.py + +# 5. Confirm outputs exist. +ls -lh benchmark/results/accuracy_vs_epsilon.png benchmark/results/privacy_cost.png + diff --git a/benchmark/scripts/run_sweep.sh b/benchmark/scripts/run_sweep.sh new file mode 100755 index 00000000000..4696ea5240a --- /dev/null +++ b/benchmark/scripts/run_sweep.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Runs FedAvg for each epsilon value and the non-private baseline, +# then evaluates accuracy. Results are appended to results/results.csv. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +JAR="$REPO_ROOT/target/SystemDS.jar" +SCRIPTS="$REPO_ROOT/benchmark/scripts" +DATA="$REPO_ROOT/benchmark/data" +RESULTS="$REPO_ROOT/benchmark/results" +mkdir -p "$RESULTS" + +# Read dataset metadata written by prepare_data.py. +source <(grep -E '^(n_train|n_test|n_features|worker[1-4]_rows)=' \ + "$DATA/meta.txt" | sed 's/=/="/;s/$/"/') + +COMMON_ARGS="\ + data_dir=$DATA \ + n_features=$n_features \ + w1_rows=$worker1_rows \ + w2_rows=$worker2_rows \ + w3_rows=$worker3_rows \ + w4_rows=$worker4_rows \ + n_rounds=300 \ + lr=1.0 \ + clip_norm=4.0 \ + delta=1e-5" + +# ── Start workers ───────────────────────────────────────────────────────── +echo "=== Starting federated workers ===" +bash "$SCRIPTS/start_workers.sh" + +run_one() { + local label="$1" # e.g. "eps_0.5" or "baseline" + local extra="$2" # extra -nvargs for this run + local model="$RESULTS/model_${label}.csv" + local acc_file="$RESULTS/acc_${label}.txt" + + echo "" + echo "--- Training: $label ---" + java --add-modules=jdk.incubator.vector -jar "$JAR" \ + -f "$SCRIPTS/fedavg_dp.dml" \ + -nvargs $COMMON_ARGS $extra out="$model" \ + 2>&1 | tee "$RESULTS/train_${label}.log" | grep -E "FedAvg|error|Error" || true + + echo " Evaluating …" + java --add-modules=jdk.incubator.vector -jar "$JAR" \ + -f "$SCRIPTS/eval.dml" \ + -nvargs data_dir="$DATA" model_path="$model" out_acc="$acc_file" \ + 2>&1 | grep "Accuracy:" +} + +# ── Non-private baseline ────────────────────────────────────────────────── +run_one "baseline" "private=0 epsilon=9999" + +# ── DP runs ─────────────────────────────────────────────────────────────── +for EPS in 0.5 1 4 8; do + LABEL="eps_${EPS}" + run_one "$LABEL" "private=1 epsilon=${EPS}" +done + +# ── Stop workers ────────────────────────────────────────────────────────── +echo "" +echo "=== Stopping federated workers ===" +bash "$SCRIPTS/stop_workers.sh" + +echo "" +echo "All runs complete. Logs and model files in $RESULTS/" + diff --git a/benchmark/scripts/start_workers.sh b/benchmark/scripts/start_workers.sh new file mode 100755 index 00000000000..23b5634230c --- /dev/null +++ b/benchmark/scripts/start_workers.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Start 4 local SystemDS federated workers on ports 8301-8304. +# Each worker is given the absolute path to its data shard directory. +set -e +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +JAR="$REPO_ROOT/target/SystemDS.jar" +DATA_DIR="$REPO_ROOT/benchmark/data" +LOG_DIR="$REPO_ROOT/benchmark/results" +mkdir -p "$LOG_DIR" + +for i in 1 2 3 4; do + PORT=$((8300 + i)) + echo "Starting worker $i on port $PORT …" + java --add-modules=jdk.incubator.vector -jar "$JAR" \ + -w "$PORT" \ + > "$LOG_DIR/worker${i}.log" 2>&1 & + echo $! > "$LOG_DIR/worker${i}.pid" +done + +# Give workers time to bind their ports. +sleep 3 +echo "Workers running. PIDs:" +for i in 1 2 3 4; do cat "$LOG_DIR/worker${i}.pid"; done + diff --git a/benchmark/scripts/stop_workers.sh b/benchmark/scripts/stop_workers.sh new file mode 100755 index 00000000000..d1ff3cfde41 --- /dev/null +++ b/benchmark/scripts/stop_workers.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +LOG_DIR="$(cd "$(dirname "$0")/../../benchmark/results" && pwd)" +for i in 1 2 3 4; do + PID_FILE="$LOG_DIR/worker${i}.pid" + if [ -f "$PID_FILE" ]; then + PID=$(cat "$PID_FILE") + kill "$PID" 2>/dev/null && echo "Stopped worker $i (PID $PID)" || true + rm -f "$PID_FILE" + fi +done + diff --git a/pom.xml b/pom.xml index 068bed2e8ea..724ee4b1f1d 100644 --- a/pom.xml +++ b/pom.xml @@ -408,6 +408,7 @@ maven-surefire-plugin ${maven-surefire-plugin.version} + plain ${maven.test.skip} ${test-parallel} ${test-threadCount} diff --git a/src/main/java/org/apache/sysds/common/Builtins.java b/src/main/java/org/apache/sysds/common/Builtins.java index f5719641df7..6dce5fb30eb 100644 --- a/src/main/java/org/apache/sysds/common/Builtins.java +++ b/src/main/java/org/apache/sysds/common/Builtins.java @@ -116,6 +116,9 @@ public enum Builtins { DECISIONTREEPREDICT("decisionTreePredict", true), DECOMPRESS("decompress", false), DEDUP("dedup", true), + DP_LAPLACE("dp_laplace", false), + DP_GAUSSIAN("dp_gaussian", false), + DP_SET_BUDGET("dp_set_budget", false), DEEPWALK("deepWalk", true), DET("det", false), DETECTSCHEMA("detectSchema", false), diff --git a/src/main/java/org/apache/sysds/common/InstructionType.java b/src/main/java/org/apache/sysds/common/InstructionType.java index e0e77c46c59..ed795d038e2 100644 --- a/src/main/java/org/apache/sysds/common/InstructionType.java +++ b/src/main/java/org/apache/sysds/common/InstructionType.java @@ -63,6 +63,7 @@ public enum InstructionType { MMChain, Union, EINSUM, + DPBuiltin, //SP Types MAPMM, diff --git a/src/main/java/org/apache/sysds/common/Opcodes.java b/src/main/java/org/apache/sysds/common/Opcodes.java index 9a894dde13b..f9ed57eae06 100644 --- a/src/main/java/org/apache/sysds/common/Opcodes.java +++ b/src/main/java/org/apache/sysds/common/Opcodes.java @@ -194,6 +194,10 @@ public enum Opcodes { LIST("list", InstructionType.BuiltinNary), EINSUM("einsum", InstructionType.BuiltinNary), + //DP built-in functions + DP_LAPLACE("dp_laplace", InstructionType.DPBuiltin), + DP_GAUSSIAN("dp_gaussian", InstructionType.DPBuiltin), + //Parametrized builtin functions AUTODIFF("autoDiff", InstructionType.ParameterizedBuiltin), CONTAINS("contains", InstructionType.ParameterizedBuiltin), diff --git a/src/main/java/org/apache/sysds/common/Types.java b/src/main/java/org/apache/sysds/common/Types.java index 624c9eed3c6..611d5011fbb 100644 --- a/src/main/java/org/apache/sysds/common/Types.java +++ b/src/main/java/org/apache/sysds/common/Types.java @@ -806,7 +806,8 @@ public static ReOrgOp valueOfByOpcode(String opcode) { /** Parameterized operations that require named variable arguments */ public enum ParamBuiltinOp { - AUTODIFF, CDF, CONTAINS, INVALID, INVCDF, GROUPEDAGG, RMEMPTY, REPLACE, REXPAND, + AUTODIFF, CDF, CONTAINS, DP_LAPLACE, DP_GAUSSIAN, INVALID, INVCDF, + GROUPEDAGG, RMEMPTY, REPLACE, REXPAND, LOWER_TRI, UPPER_TRI, TRANSFORMAPPLY, TRANSFORMDECODE, TRANSFORMCOLMAP, TRANSFORMMETA, TOKENIZE, TOSTRING, LIST, PARAMSERV diff --git a/src/main/java/org/apache/sysds/hops/ParameterizedBuiltinOp.java b/src/main/java/org/apache/sysds/hops/ParameterizedBuiltinOp.java index 61a4b8b8f91..3997d3402c8 100644 --- a/src/main/java/org/apache/sysds/hops/ParameterizedBuiltinOp.java +++ b/src/main/java/org/apache/sysds/hops/ParameterizedBuiltinOp.java @@ -182,7 +182,7 @@ public Lop constructLops() } case CONTAINS: case CDF: - case INVCDF: + case INVCDF: case REPLACE: case LOWER_TRI: case UPPER_TRI: @@ -194,7 +194,9 @@ public Lop constructLops() case TOSTRING: case PARAMSERV: case LIST: - case AUTODIFF:{ + case AUTODIFF: + case DP_LAPLACE: + case DP_GAUSSIAN:{ ParameterizedBuiltin pbilop = new ParameterizedBuiltin( inputlops, _op, getDataType(), getValueType(), et); if( isMultiThreadedOpType() ) @@ -688,7 +690,17 @@ else if( _op == ParamBuiltinOp.TRANSFORMAPPLY ) { return new MatrixCharacteristics(dc.getRows(), dc.getCols(), -1, dc.getLength()); } } - + else if( _op == ParamBuiltinOp.DP_LAPLACE || _op == ParamBuiltinOp.DP_GAUSSIAN ) { + if( dc.dimsKnown() ) { + Hop query = getParameterHop("query"); + String queryVal = (query instanceof LiteralOp) ? ((LiteralOp)query).getStringValue() : null; + if( "colMeans".equals(queryVal) || "colSums".equals(queryVal) ) + ret = new MatrixCharacteristics(1, dc.getCols(), -1, dc.getCols()); + else if( "identity".equals(queryVal) ) + ret = new MatrixCharacteristics(dc.getRows(), dc.getCols(), -1, dc.getLength()); + } + } + return ret; } @Override @@ -758,7 +770,8 @@ && getTargetHop().areDimsBelowThreshold() ) { if (_op == ParamBuiltinOp.TRANSFORMCOLMAP || _op == ParamBuiltinOp.TRANSFORMMETA || _op == ParamBuiltinOp.TOSTRING || _op == ParamBuiltinOp.LIST || _op == ParamBuiltinOp.CDF || _op == ParamBuiltinOp.INVCDF - || _op == ParamBuiltinOp.PARAMSERV) { + || _op == ParamBuiltinOp.PARAMSERV + || _op == ParamBuiltinOp.DP_LAPLACE || _op == ParamBuiltinOp.DP_GAUSSIAN) { _etype = ExecType.CP; } diff --git a/src/main/java/org/apache/sysds/lops/ParameterizedBuiltin.java b/src/main/java/org/apache/sysds/lops/ParameterizedBuiltin.java index 3604121aac8..48ecbaa72df 100644 --- a/src/main/java/org/apache/sysds/lops/ParameterizedBuiltin.java +++ b/src/main/java/org/apache/sysds/lops/ParameterizedBuiltin.java @@ -204,7 +204,19 @@ public String getInstructions(String output) compileGenericParamMap(sb, _inputParams); break; } - + case DP_LAPLACE: { + sb.append(Opcodes.DP_LAPLACE); + sb.append(OPERAND_DELIMITOR); + compileGenericParamMap(sb, _inputParams); + break; + } + case DP_GAUSSIAN: { + sb.append(Opcodes.DP_GAUSSIAN); + sb.append(OPERAND_DELIMITOR); + compileGenericParamMap(sb, _inputParams); + break; + } + default: throw new LopsException(this.printErrorLocation() + "In ParameterizedBuiltin Lop, Unknown operation: " + _operation); } diff --git a/src/main/java/org/apache/sysds/parser/BuiltinFunctionExpression.java b/src/main/java/org/apache/sysds/parser/BuiltinFunctionExpression.java index ab0c7993b4e..1c2c6ba8110 100644 --- a/src/main/java/org/apache/sysds/parser/BuiltinFunctionExpression.java +++ b/src/main/java/org/apache/sysds/parser/BuiltinFunctionExpression.java @@ -2005,6 +2005,59 @@ else if(this.getOpCode() == Builtins.MAX_POOL || this.getOpCode() == Builtins.AV } else raiseValidateError("Local instruction not allowed in dml script"); + case DP_LAPLACE: { + checkNumParameters(4); + checkMatrixParam(getFirstExpr()); + checkScalarParam(getSecondExpr()); + checkValueTypeParam(getSecondExpr(), ValueType.STRING); + checkScalarParam(getThirdExpr()); + checkScalarParam(getFourthExpr()); + String dpLaplaceQuery = getDPQueryLiteral(getSecondExpr()); + long[] dpLaplaceDims = getDPOutputDims(dpLaplaceQuery, + getFirstExpr().getOutput().getDim1(), getFirstExpr().getOutput().getDim2()); + output.setDataType(DataType.MATRIX); + output.setValueType(ValueType.FP64); + output.setDimensions(dpLaplaceDims[0], dpLaplaceDims[1]); + break; + } + case DP_GAUSSIAN: { + checkNumParameters(5); + checkMatrixParam(getFirstExpr()); + checkScalarParam(getSecondExpr()); + checkValueTypeParam(getSecondExpr(), ValueType.STRING); + checkScalarParam(getThirdExpr()); + checkScalarParam(getFourthExpr()); + checkScalarParam(getFifthExpr()); + String dpGaussianQuery = getDPQueryLiteral(getSecondExpr()); + long[] dpGaussianDims = getDPOutputDims(dpGaussianQuery, + getFirstExpr().getOutput().getDim1(), getFirstExpr().getOutput().getDim2()); + output.setDataType(DataType.MATRIX); + output.setValueType(ValueType.FP64); + output.setDimensions(dpGaussianDims[0], dpGaussianDims[1]); + break; + } + case DP_SET_BUDGET: { + checkNumParameters(2); + checkScalarParam(getFirstExpr()); + checkScalarParam(getSecondExpr()); + // resolved entirely at compile time (see DMLTranslator), so both + // arguments must be known before the HOP DAG is built. + if (!isConstant(getFirstExpr()) || !isConstant(getSecondExpr())) + raiseValidateError(getOpCode() + ": 'epsilon' and 'delta' must be compile-time numeric literals", + false, LanguageErrorCodes.INVALID_PARAMETERS); + double dpSetBudgetEpsilon = getDoubleValue(getFirstExpr()); + double dpSetBudgetDelta = getDoubleValue(getSecondExpr()); + if (dpSetBudgetEpsilon <= 0) + raiseValidateError(getOpCode() + ": epsilon must be > 0, got " + dpSetBudgetEpsilon, + false, LanguageErrorCodes.INVALID_PARAMETERS); + if ((dpSetBudgetDelta <= 0) || (dpSetBudgetDelta >= 1)) + raiseValidateError(getOpCode() + ": delta must be in (0,1), got " + dpSetBudgetDelta, + false, LanguageErrorCodes.INVALID_PARAMETERS); + output.setDataType(DataType.SCALAR); + output.setValueType(ValueType.FP64); + output.setDimensions(0, 0); + break; + } case COMPRESS: case DECOMPRESS: if(OptimizerUtils.ALLOW_SCRIPT_LEVEL_COMPRESS_COMMAND){ @@ -2111,6 +2164,34 @@ else if(this.getOpCode() == Builtins.MAX_POOL || this.getOpCode() == Builtins.AV } } + /** + * dp_laplace/dp_gaussian require the "query" parameter to be a compile-time + * string literal so that the output shape (and thus the transformation + * matrix T built at runtime) is known during validation. + */ + private String getDPQueryLiteral(Expression queryExpr) { + if (!(queryExpr instanceof StringIdentifier)) + raiseValidateError(getOpCode() + ": 'query' must be a string literal", false, + LanguageErrorCodes.INVALID_PARAMETERS); + return ((StringIdentifier) queryExpr).getValue(); + } + + /** Output dimensions of T %*% X for the given named query, X being n x d. */ + private long[] getDPOutputDims(String query, long n, long d) { + switch (query) { + case "colMeans": + case "colSums": + return new long[] {1, d}; + case "identity": + return new long[] {n, d}; + default: + raiseValidateError(getOpCode() + ": unknown query type '" + query + + "' (expected colMeans, colSums, or identity)", false, + LanguageErrorCodes.INVALID_PARAMETERS); + return null; // unreachable + } + } + private void validateEinsum(DataIdentifier output){ if(getSecondExpr() == null) raiseValidateError("Einsum: at least one input matrix required", false, diff --git a/src/main/java/org/apache/sysds/parser/DMLProgram.java b/src/main/java/org/apache/sysds/parser/DMLProgram.java index 2f69cb7ea0d..d2c0b9ae7d3 100644 --- a/src/main/java/org/apache/sysds/parser/DMLProgram.java +++ b/src/main/java/org/apache/sysds/parser/DMLProgram.java @@ -36,7 +36,21 @@ public class DMLProgram private ArrayList _blocks; private Map> _namespaces; private boolean _containsRemoteParfor; - + + /** + * Session-wide differential privacy budget resolved at compile time from a + * {@code dp_set_budget(epsilon, delta)} call (its arguments must be numeric + * literals — see {@code BuiltinFunctionExpression}). Null until such a call + * is encountered during HOP construction; consulted by + * {@code ExecutionContext#getDPBudgetAccountant()} in place of its hardcoded + * default. This is deliberately a plain field (not a Hop/Lop/Instruction): + * since {@code Program} holds a reference back to this {@code DMLProgram} + * (see {@code Program#getDMLProg()}), the value survives from compile time + * through to runtime without needing a runtime instruction at all. + */ + private Double _dpBudgetEpsilon; + private Double _dpBudgetDelta; + public DMLProgram(){ _blocks = new ArrayList<>(); _namespaces = new HashMap<>(); @@ -67,6 +81,23 @@ public void setContainsRemoteParfor(boolean flag) { public boolean containsRemoteParfor() { return _containsRemoteParfor; } + + public void setDPBudget(double epsilon, double delta) { + _dpBudgetEpsilon = epsilon; + _dpBudgetDelta = delta; + } + + public boolean hasDPBudget() { + return _dpBudgetEpsilon != null; + } + + public double getDPBudgetEpsilon() { + return _dpBudgetEpsilon; + } + + public double getDPBudgetDelta() { + return _dpBudgetDelta; + } public static boolean isInternalNamespace(String namespace) { return DEFAULT_NAMESPACE.equals(namespace) diff --git a/src/main/java/org/apache/sysds/parser/DMLTranslator.java b/src/main/java/org/apache/sysds/parser/DMLTranslator.java index a8e1667d049..a9d5279c26b 100644 --- a/src/main/java/org/apache/sysds/parser/DMLTranslator.java +++ b/src/main/java/org/apache/sysds/parser/DMLTranslator.java @@ -2589,6 +2589,53 @@ else if ( sop.equalsIgnoreCase(Opcodes.NOTEQUAL.toString()) ) case DECOMPRESS: currBuiltinOp = new UnaryOp(target.getName(), target.getDataType(), ValueType.FP64, OpOp1.DECOMPRESS, expr); break; + case DP_LAPLACE: { + String[] dpLaplaceParamNames = {"target", "query", "sensitivity", "epsilon"}; + LinkedHashMap dpLaplaceParams = new LinkedHashMap<>(); + dpLaplaceParams.put(dpLaplaceParamNames[0], expr); + dpLaplaceParams.put(dpLaplaceParamNames[1], expr2); + dpLaplaceParams.put(dpLaplaceParamNames[2], expr3); + for (int i = 3; i < dpLaplaceParamNames.length; i++) { + dpLaplaceParams.put(dpLaplaceParamNames[i], + source.getExpr(i) != null ? processExpression(source.getExpr(i), null, hops) : null); + } + currBuiltinOp = new ParameterizedBuiltinOp(target.getName(), DataType.MATRIX, ValueType.FP64, + ParamBuiltinOp.DP_LAPLACE, dpLaplaceParams); + break; + } + case DP_GAUSSIAN: { + String[] dpGaussianParamNames = {"target", "query", "sensitivity", "epsilon", "delta"}; + LinkedHashMap dpGaussianParams = new LinkedHashMap<>(); + dpGaussianParams.put(dpGaussianParamNames[0], expr); + dpGaussianParams.put(dpGaussianParamNames[1], expr2); + dpGaussianParams.put(dpGaussianParamNames[2], expr3); + for (int i = 3; i < dpGaussianParamNames.length; i++) { + dpGaussianParams.put(dpGaussianParamNames[i], + source.getExpr(i) != null ? processExpression(source.getExpr(i), null, hops) : null); + } + currBuiltinOp = new ParameterizedBuiltinOp(target.getName(), DataType.MATRIX, ValueType.FP64, + ParamBuiltinOp.DP_GAUSSIAN, dpGaussianParams); + break; + } + case DP_SET_BUDGET: { + // Resolved entirely at compile time: BuiltinFunctionExpression.validateExpression + // already enforced that both arguments are numeric literals, so 'expr'/'expr2' are + // guaranteed LiteralOps here. There is deliberately no runtime Hop/Lop/Instruction for + // this call — the budget is applied directly to the DMLProgram (reachable later from + // ExecutionContext via Program.getDMLProg(), see ExecutionContext.getDPBudgetAccountant()) + // before any instruction executes, so there is nothing for the DAG linearizer to reorder + // or drop as dead code. + if (_dmlProg.hasDPBudget()) + throw new LanguageException(source.getOpCode() + ": dp_set_budget may only be called once per " + + "script (already set to epsilon=" + _dmlProg.getDPBudgetEpsilon() + + ", delta=" + _dmlProg.getDPBudgetDelta() + ")"); + if (!(expr instanceof LiteralOp) || !(expr2 instanceof LiteralOp)) + throw new LanguageException(source.getOpCode() + + ": epsilon and delta must be compile-time numeric literals"); + _dmlProg.setDPBudget(((LiteralOp) expr).getDoubleValue(), ((LiteralOp) expr2).getDoubleValue()); + currBuiltinOp = expr; // echo epsilon back as confirmation + break; + } case QUANTIZE_COMPRESS: currBuiltinOp = new BinaryOp(target.getName(), target.getDataType(), target.getValueType(), OpOp2.valueOf(source.getOpCode().name()), expr, expr2); break; diff --git a/src/main/java/org/apache/sysds/runtime/controlprogram/context/ExecutionContext.java b/src/main/java/org/apache/sysds/runtime/controlprogram/context/ExecutionContext.java index 67cda352a73..7f25b2364fe 100644 --- a/src/main/java/org/apache/sysds/runtime/controlprogram/context/ExecutionContext.java +++ b/src/main/java/org/apache/sysds/runtime/controlprogram/context/ExecutionContext.java @@ -28,6 +28,7 @@ import org.apache.sysds.conf.ConfigurationManager; import org.apache.sysds.hops.OptimizerUtils; import org.apache.sysds.hops.fedplanner.FTypes.FType; +import org.apache.sysds.parser.DMLProgram; import org.apache.sysds.runtime.DMLRuntimeException; import org.apache.sysds.runtime.controlprogram.LocalVariableMap; import org.apache.sysds.runtime.controlprogram.Program; @@ -62,6 +63,7 @@ import org.apache.sysds.runtime.meta.MetaData; import org.apache.sysds.runtime.meta.MetaDataFormat; import org.apache.sysds.runtime.util.HDFSTool; +import org.apache.sysds.runtime.privacy.dp.DPBudgetAccountant; import org.apache.sysds.utils.Statistics; import java.util.ArrayList; @@ -90,6 +92,8 @@ public class ExecutionContext { protected SEALClient _seal_client; + private DPBudgetAccountant _dpBudgetAccountant = null; + //parfor temporary functions (created by eval) protected Set _fnNames; @@ -144,6 +148,23 @@ public void setLineage(Lineage lineage) { _lineage = lineage; } + /** + * Returns the session-scoped {@link DPBudgetAccountant}, lazily initialised on + * first use. If the DML script called {@code dp_set_budget(epsilon, delta)} + * with compile-time literal arguments, that value (resolved onto the + * {@code DMLProgram} during HOP construction — see {@code DMLTranslator}'s + * {@code DP_SET_BUDGET} case) is used instead of the hardcoded defaults. + */ + public DPBudgetAccountant getDPBudgetAccountant() { + if (_dpBudgetAccountant == null) { + DMLProgram dmlProg = (_prog != null) ? _prog.getDMLProg() : null; + _dpBudgetAccountant = (dmlProg != null && dmlProg.hasDPBudget()) + ? new DPBudgetAccountant(dmlProg.getDPBudgetEpsilon(), dmlProg.getDPBudgetDelta()) + : new DPBudgetAccountant(); + } + return _dpBudgetAccountant; + } + public boolean isAutoCreateVars() { return _autoCreateVars; } diff --git a/src/main/java/org/apache/sysds/runtime/instructions/CPInstructionParser.java b/src/main/java/org/apache/sysds/runtime/instructions/CPInstructionParser.java index 92e11b425dd..ca6baf058b4 100644 --- a/src/main/java/org/apache/sysds/runtime/instructions/CPInstructionParser.java +++ b/src/main/java/org/apache/sysds/runtime/instructions/CPInstructionParser.java @@ -65,6 +65,7 @@ import org.apache.sysds.runtime.instructions.cp.UnaryCPInstruction; import org.apache.sysds.runtime.instructions.cp.VariableCPInstruction; import org.apache.sysds.runtime.instructions.cp.UnionCPInstruction; +import org.apache.sysds.runtime.instructions.cp.DPBuiltinCPInstruction; import org.apache.sysds.runtime.instructions.cp.EinsumCPInstruction; import org.apache.sysds.runtime.instructions.cpfile.MatrixIndexingCPFileInstruction; @@ -226,7 +227,10 @@ public static CPInstruction parseSingleInstruction ( InstructionType cptype, Str case EINSUM: return EinsumCPInstruction.parseInstruction(str); - + + case DPBuiltin: + return DPBuiltinCPInstruction.parseInstruction(str); + default: throw new DMLRuntimeException("Invalid CP Instruction Type: " + cptype ); } diff --git a/src/main/java/org/apache/sysds/runtime/instructions/cp/CPInstruction.java b/src/main/java/org/apache/sysds/runtime/instructions/cp/CPInstruction.java index b8d84ca3898..668d2f36978 100644 --- a/src/main/java/org/apache/sysds/runtime/instructions/cp/CPInstruction.java +++ b/src/main/java/org/apache/sysds/runtime/instructions/cp/CPInstruction.java @@ -48,7 +48,8 @@ public enum CPType { EvictLineageCache, EINSUM, NoOp, Union, - QuantizeCompression + QuantizeCompression, + DPBuiltin } protected final CPType _cptype; diff --git a/src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinCPInstruction.java b/src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinCPInstruction.java new file mode 100755 index 00000000000..1ca7d75bf86 --- /dev/null +++ b/src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinCPInstruction.java @@ -0,0 +1,428 @@ +/* + * 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. + */ + +package org.apache.sysds.runtime.instructions.cp; + +import org.apache.sysds.runtime.DMLRuntimeException; +import org.apache.sysds.runtime.controlprogram.context.ExecutionContext; +import org.apache.sysds.runtime.instructions.InstructionUtils; +import org.apache.sysds.runtime.matrix.data.LibMatrixMult; +import org.apache.sysds.runtime.matrix.data.LibMatrixReorg; +import org.apache.sysds.runtime.matrix.data.MatrixBlock; +import org.apache.sysds.runtime.privacy.dp.DPBudgetAccountant; + +import java.util.LinkedHashMap; +import java.util.concurrent.ThreadLocalRandom; + +/** + * CP instruction for differential-privacy release of a linear query over the + * original matrix. + * + * DML syntax (raw-matrix form): + * result = dp_laplace(X, query="colMeans", sensitivity=1.0, epsilon=0.5) + * result = dp_gaussian(X, query="colMeans", sensitivity=1.0, epsilon=0.5, delta=1e-5) + * + * The instruction receives the original {@code n x d} matrix {@code X}, + * builds a transformation matrix {@code T} ({@code k x n}) from the named + * {@code query} (see {@link #buildTransform}), and returns a noisy release of + * {@code T %*% X}. The noise is not added as a separate elementwise + * pass over a materialised aggregate: it is injected by augmenting {@code T} + * with an identity block and {@code X} with the noise matrix, so that the + * noisy release is the result of a single {@link LibMatrixMult#matrixMult} + * call (see {@link #processInstruction} for the derivation). + * + * Sensitivity norm: {@code sensitivity} is not interchangeable + * between the two builtins. {@code dp_laplace} calibrates its noise scale + * to the L1 sensitivity of {@code T %*% X} to a single-record + * change; {@code dp_gaussian} calibrates its σ to the L2 sensitivity. + * For a scalar release (e.g. {@code query="colMeans"} on single-column + * {@code X}) the two norms coincide, but for a vector- or matrix-valued + * release they generally differ — the caller is responsible for supplying + * the norm matching the builtin invoked (see {@link #sensitivityOf}). + * + * The {@link #sensitivityOf} method is deliberately separated from the + * noise-scale computation. It currently returns the caller-supplied + * constant. A future rewrite pass could replace the body of this single + * method with a static analysis that derives sensitivity from {@code T}'s + * column norms and a declared per-record bound on {@code X}; every other + * line in this class would stay unchanged. + */ +public class DPBuiltinCPInstruction extends ComputationCPInstruction { + + // ----------------------------------------------------------------------- + // Constants + // ----------------------------------------------------------------------- + + /** Opcode registered in Builtins and CPInstructionParser. */ + public static final String OPCODE_LAPLACE = "dp_laplace"; + public static final String OPCODE_GAUSSIAN = "dp_gaussian"; + + // ----------------------------------------------------------------------- + // Fields + // ----------------------------------------------------------------------- + + /** + * Named parameters extracted from the serialised instruction string. + * Keys: "target", "query", "sensitivity", "epsilon", "delta" (Gaussian only). + * + * Using the same LinkedHashMap convention as + * ParameterizedBuiltinCPInstruction so that CPInstructionParser can + * call the shared constructParameterMap() helper unchanged. + */ + private final LinkedHashMap _params; + + // ----------------------------------------------------------------------- + // Constructor (private – use parseInstruction) + // ----------------------------------------------------------------------- + + private DPBuiltinCPInstruction( + CPOperand input, + CPOperand output, + String opcode, + String istr, + LinkedHashMap params) { + super(CPType.DPBuiltin, null, input, null, output, opcode, istr); + _params = params; + } + + // ----------------------------------------------------------------------- + // Static factory / parser + // ----------------------------------------------------------------------- + + /** + * Reconstructs a {@code DPBuiltinCPInstruction} from its serialised + * instruction string produced by the LOP layer. + * + * Expected format (OPERAND_DELIM = '\u00b0'): + * dp_gaussian°target=mVar1·MATRIX·FP64°query=colMeans·SCALAR·STRING·true + * °sensitivity=1.0·SCALAR·FP64·true°epsilon=0.5·SCALAR·FP64·true + * °delta=1e-5·SCALAR·FP64·true°_mVar2·MATRIX·FP64 + * + * The first token is always the opcode; the last token is always the + * output operand; the tokens in between are key=value pairs. This matches + * the convention used by ParameterizedBuiltinCPInstruction exactly. + */ + public static DPBuiltinCPInstruction parseInstruction(String str) { + String[] parts = InstructionUtils.getInstructionPartsWithValueType(str); + InstructionUtils.checkNumFields(parts, 5, 6); // laplace=5, gaussian=6 + String opcode = parts[0]; + + // Output operand is always the last token. + CPOperand output = new CPOperand(parts[parts.length - 1]); + + // The "target" parameter holds the variable name of the input matrix. + // ParameterizedBuiltinCPInstruction.constructParameterMap strips the + // type suffixes and returns bare key=value pairs. + LinkedHashMap params = + ParameterizedBuiltinCPInstruction.constructParameterMap(parts); + + // The target CPOperand is needed by ComputationCPInstruction's + // getInputs() / getLineageItem() machinery. + CPOperand input = new CPOperand(params.get("target"), + org.apache.sysds.common.Types.ValueType.FP64, + org.apache.sysds.common.Types.DataType.MATRIX); + + // Validate required keys. + if (!params.containsKey("query")) + throw new DMLRuntimeException(opcode + ": missing 'query'"); + if (!params.containsKey("sensitivity")) + throw new DMLRuntimeException(opcode + ": missing 'sensitivity'"); + if (!params.containsKey("epsilon")) + throw new DMLRuntimeException(opcode + ": missing 'epsilon'"); + if (opcode.equals(OPCODE_GAUSSIAN) && !params.containsKey("delta")) + throw new DMLRuntimeException(opcode + ": missing 'delta'"); + + return new DPBuiltinCPInstruction(input, output, opcode, str, params); + } + + // ----------------------------------------------------------------------- + // Core execution + // ----------------------------------------------------------------------- + + /** + * Executes the DP release. + * + * - Read the original {@link MatrixBlock} {@code X} from the variable + * table. + * - Build the transformation matrix {@code T} ({@code k x n}) from + * {@code query} (see {@link #buildTransform}). + * - Determine sensitivity via {@link #sensitivityOf}. + * - Generate a noise {@link MatrixBlock} shaped {@code k x d}. + * - Fuse {@code T %*% X + noise} into a single + * {@link LibMatrixMult#matrixMult} call (see below). + * - Record the release with the session-scoped + * {@link DPBudgetAccountant}; throw if budget is exhausted. + * - Write the noisy block back to the variable table and release + * the input pin. + * + * Fusion derivation: for {@code T} ({@code k x n}), {@code X} + * ({@code n x d}) and noise {@code N} ({@code k x d}), let + * {@code T' = [T | I_k]} ({@code k x (n+k)}) and + * {@code X' = [X ; N]} ({@code (n+k) x d}). Then + * {@code T' %*% X' = T %*% X + I_k %*% N = T %*% X + N}, computed as one + * matrix multiply instead of a multiply followed by a separate + * elementwise add. + */ + @Override + public void processInstruction(ExecutionContext ec) { + + // ── 1. Read original input matrix X ───────────────────────────────── + // getMatrixInput pins the block in memory and increments the + // reference count; we must call releaseMatrixInput afterwards. + MatrixBlock X = ec.getMatrixInput(_params.get("target")); + + // ── 2. Parse DP parameters ────────────────────────────────────────── + double epsilon = parsePositiveDouble("epsilon"); + double delta = instOpcode.equals(OPCODE_GAUSSIAN) + ? parsePositiveDouble("delta") : 0.0; + String query = _params.get("query"); + + // ── 3. Build the transformation matrix T (k x n) ──────────────────── + MatrixBlock T = buildTransform(query, X.getNumRows()); + + // ── 4. Determine sensitivity (caller-supplied constant) ───────────── + double sensitivity = sensitivityOf(T); + + // ── 5. Generate noise shaped like the release T %*% X (k x d) ─────── + MatrixBlock noiseBlock = generateNoise(T.getNumRows(), X.getNumColumns(), + sensitivity, epsilon, delta); + + // ── 6. Fuse T %*% X + noise into a single matrix multiply ─────────── + MatrixBlock Ik = identity(T.getNumRows()); + MatrixBlock Tp = T.append(Ik, null, true); // [T | I_k] + MatrixBlock Xp = X.append(noiseBlock, null, false); // [X ; noise] + MatrixBlock outBlock = LibMatrixMult.matrixMult(Tp, Xp); + + // ── 7. Record release and enforce budget ──────────────────────────── + // getDPBudgetAccountant() returns a lazy-initialised DPBudgetAccountant that is + // owned by this ExecutionContext (added in a companion EC patch). + DPBudgetAccountant accountant = ec.getDPBudgetAccountant(); + accountant.compose(epsilon, delta, sensitivity); // throws on exhaustion + + // ── 8. Write output and release input pin ─────────────────────────── + ec.releaseMatrixInput(_params.get("target")); + ec.setMatrixOutput(output.getName(), outBlock); + } + + // ----------------------------------------------------------------------- + // Transformation matrix construction + // ----------------------------------------------------------------------- + + /** + * Builds the {@code k x n} transformation matrix {@code T} for the given + * named query, to be left-multiplied against the {@code n x d} input + * {@code X} as {@code T %*% X}. + * + * - {@code "colMeans"}: {@code T} is {@code 1 x n}, filled with + * {@code 1/n} — {@code T %*% X} is the column-mean row vector. + * - {@code "colSums"}: {@code T} is {@code 1 x n}, filled with + * {@code 1.0} — {@code T %*% X} is the column-sum row vector. + * - {@code "identity"}: {@code T} is the {@code n x n} identity + * (built sparsely via {@link #identity}) — {@code T %*% X} is + * {@code X} itself, i.e. a noisy release of the raw matrix. + * + * Row-wise aggregates ({@code rowMeans}/{@code rowSums}) reduce across + * the feature axis of {@code X}, i.e. they are naturally + * {@code X %*% T'} (right-multiply), not {@code T %*% X}, so they are + * intentionally not supported here. + */ + private static MatrixBlock buildTransform(String query, int n) { + switch (query) { + case "colMeans": { + MatrixBlock T = new MatrixBlock(1, n, false); + T.allocateDenseBlock(); + double v = 1.0 / n; + for (int c = 0; c < n; c++) + T.set(0, c, v); + T.recomputeNonZeros(); + return T; + } + case "colSums": { + MatrixBlock T = new MatrixBlock(1, n, false); + T.allocateDenseBlock(); + for (int c = 0; c < n; c++) + T.set(0, c, 1.0); + T.recomputeNonZeros(); + return T; + } + case "identity": + return identity(n); + default: + throw new DMLRuntimeException( + "dp_laplace/dp_gaussian: unknown query type '" + query + + "' (expected colMeans, colSums, or identity)"); + } + } + + /** + * Builds a {@code k x k} identity matrix, sparsely, by reusing the + * existing {@link LibMatrixReorg#diag} reorg operator (the same runtime + * path DML's {@code diag()} builtin uses to expand a vector into a + * diagonal matrix). Keeps memory {@code O(k)} rather than {@code O(k^2)}, + * which matters for the {@code query="identity"} case where {@code k} + * equals the number of rows of {@code X}. + */ + private static MatrixBlock identity(int k) { + MatrixBlock ones = new MatrixBlock(k, 1, false); + ones.allocateDenseBlock(); + for (int i = 0; i < k; i++) + ones.set(i, 0, 1.0); + ones.recomputeNonZeros(); + return LibMatrixReorg.diag(ones, new MatrixBlock(k, k, true)); + } + + // ----------------------------------------------------------------------- + // Sensitivity seam + // ----------------------------------------------------------------------- + + /** + * Returns the sensitivity of the release {@code T %*% X} to a + * single-record change, in the norm required by the mechanism actually + * invoked: L1 for {@code dp_laplace}, L2 for + * {@code dp_gaussian} (see the class Javadoc). The two only coincide + * when the release is scalar. + * + * Returns the caller-supplied literal from the DML script as-is, with + * no norm conversion or validation — the DML author must compute the + * sensitivity in the correct norm for the builtin they call. A future + * rewrite pass could replace this body with an analysis that derives + * sensitivity from {@code T}'s column norms and a declared per-record + * bound on {@code X}; no other line in this class would need to change. + * + * @param T the transformation matrix (unused for now; kept as the seam + * for a future sensitivity-derivation pass) + * @return caller-supplied sensitivity constant, expected to already be + * in the L1 norm (Laplace) or L2 norm (Gaussian) + */ + private double sensitivityOf(MatrixBlock T) { + return parsePositiveDouble("sensitivity"); + } + + // ----------------------------------------------------------------------- + // Noise generation + // ----------------------------------------------------------------------- + + /** + * Generates a {@code rows x cols} noise {@link MatrixBlock} — matching + * the shape of the release {@code T %*% X} — filled with samples from the + * mechanism-appropriate distribution calibrated to ({@code sensitivity}, + * {@code epsilon}, {@code delta}). + * + * Both mechanisms produce a dense block. Sparsity exploitation is + * left for future work; for the releases targeted here (e.g. column + * means, column sums) the noise is dense regardless. + */ + private MatrixBlock generateNoise( + int rows, + int cols, + double sensitivity, + double epsilon, + double delta) { + + MatrixBlock noise = new MatrixBlock(rows, cols, false); // dense + noise.allocateDenseBlock(); + + if (instOpcode.equals(OPCODE_LAPLACE)) { + // Laplace mechanism + // For a given epsilon, noise is drawn from the Laplace distribution at + // scale b = sensitivity / epsilon + fillLaplaceNoise(noise, sensitivity / epsilon); + } else { + // Gaussian mechanism: calibrate sigma for (epsilon, delta)-DP. + // For a given epsilon, noise is drawn from the normal distribution at + // sigma^2 = 2 * sensitivity^2 * log(1.25/delta) / epsilon^2 + double sigma = sensitivity + * Math.sqrt(2.0 * Math.log(1.25 / delta)) + / epsilon; + fillGaussianNoise(noise, sigma); + } + + noise.recomputeNonZeros(); + return noise; + } + + /** + * Fills {@code block} with i.i.d. Laplace(0, scale) samples using the + * inverse-CDF method. + * + * For u ~ Uniform(0, 1): X = -scale * sign(u - 0.5) * ln(1 - 2|u - 0.5|) + */ + private static void fillLaplaceNoise(MatrixBlock block, double scale) { + ThreadLocalRandom rng = ThreadLocalRandom.current(); + int rows = block.getNumRows(); + int cols = block.getNumColumns(); + for (int r = 0; r < rows; r++) { + for (int c = 0; c < cols; c++) { + double u = rng.nextDouble(); // u in (0, 1) + double v = u - 0.5; + // Guard against the degenerate u == 0.5 case (ln(0) = -inf). + if (v == 0.0) v = 1e-15; + double sample = -scale * Math.signum(v) * Math.log(1.0 - 2.0 * Math.abs(v)); + block.set(r, c, sample); + } + } + } + + /** + * Fills {@code block} with i.i.d. N(0, sigma²) samples. + * + * Uses {@link ThreadLocalRandom#nextGaussian()} which is thread-safe + * and does not require external libraries. + */ + private static void fillGaussianNoise(MatrixBlock block, double sigma) { + ThreadLocalRandom rng = ThreadLocalRandom.current(); + int rows = block.getNumRows(); + int cols = block.getNumColumns(); + for (int r = 0; r < rows; r++) { + for (int c = 0; c < cols; c++) { + block.set(r, c, sigma * rng.nextGaussian()); + } + } + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + /** + * Parses a parameter value as a positive {@code double}. + * + * @throws DMLRuntimeException if the key is absent, unparseable, or + * non-positive + */ + private double parsePositiveDouble(String key) { + String raw = _params.get(key); + if (raw == null) + throw new DMLRuntimeException( + instOpcode + ": parameter '" + key + "' is missing"); + double v; + try { + v = Double.parseDouble(raw); + } catch (NumberFormatException e) { + throw new DMLRuntimeException( + instOpcode + ": parameter '" + key + + "' is not a valid number: " + raw); + } + if (!(v > 0.0)) + throw new DMLRuntimeException( + instOpcode + ": parameter '" + key + + "' must be strictly positive, got " + v); + return v; + } +} diff --git a/src/main/java/org/apache/sysds/runtime/privacy/dp/DPBudgetAccountant.java b/src/main/java/org/apache/sysds/runtime/privacy/dp/DPBudgetAccountant.java new file mode 100644 index 00000000000..11ddc3ac79f --- /dev/null +++ b/src/main/java/org/apache/sysds/runtime/privacy/dp/DPBudgetAccountant.java @@ -0,0 +1,277 @@ +/* + * 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. + */ + +package org.apache.sysds.runtime.privacy.dp; + +import org.apache.sysds.runtime.DMLRuntimeException; +import org.apache.sysds.runtime.instructions.cp.DPBuiltinCPInstruction; + +/** + * Session-scoped differential privacy budget accountant. + * + * Tracks composition of DP releases across the lifetime of a DML script + * execution. Each call to {@link #compose} records one release and checks + * whether the cumulative privacy cost has exceeded the user-specified budget. + * + * The mechanism type (Laplace vs Gaussian) is inferred from the {@code delta} + * argument passed to {@link #compose}: + * + * - Laplace (delta == 0): pure ε-DP. The budget cost is tracked via + * basic composition — each release contributes exactly its ε to a running + * sum. This is the tightest possible bound for pure DP and avoids the + * looser estimate that results from routing Laplace through the RDP + * conversion path (which would introduce an unnecessary δ). Noise scale + * is calibrated to L1 sensitivity (see {@link #compose}). + * - Gaussian (delta > 0): (ε, δ)-DP via Rényi DP composition. + * Rényi divergences at a discrete set of orders α compose additively; + * the accumulated sum is converted to (ε, δ) at query time using the + * formula from Mironov 2017. This is substantially tighter than basic + * composition for repeated Gaussian releases, which is the common case + * in federated learning. + * + * When both mechanisms are used in the same script the total cost is: + * ε_total = ε_Laplace_sum + ε_Gaussian_RDP + * This follows from basic composition of a pure-DP mechanism with an + * approximate-DP mechanism, which is additive in ε. + * + * Rényi orders tracked (Gaussian path) + * α ∈ {2, 4, 8, 16, 32, 64, 128, 256, 512, 1024}. At query time the minimum + * converted ε across all orders is taken as the tightest available bound. + * + * Gaussian RDP divergence + * For the Gaussian mechanism with noise scale σ and L2 sensitivity Δf: + * D_α = α · Δf² / (2σ²) + * σ is back-derived from the caller's (ε, δ) via the standard calibration + * formula (see {@link #gaussianSigma}). Note that sensitivity cancels in the + * final expression, so the RDP cost depends only on the (ε, δ) parameters. + * + * RDP → (ε, δ) conversion (Mironov 2017, Proposition 3) + * ε(α) = R[α] + log(1 − 1/α) − log(δ·(α−1)) / α + * + * One instance is created per {@code ExecutionContext} (lazy init). It is + * garbage-collected with the context when the script finishes; no state + * leaks between script executions or between concurrent scripts. + * + * Not thread-safe. A single DML script executes instructions sequentially + * on one thread, so no synchronisation is needed. + * + * @see DPBuiltinCPInstruction + */ +public class DPBudgetAccountant { + + // ----------------------------------------------------------------------- + // Rényi orders used for Gaussian composition + // ----------------------------------------------------------------------- + + private static final double DEFAULT_EPSILON_BUDGET = 1.0; + + private static final double DEFAULT_DELTA = 1e-5; + + /** + * Discrete set of Rényi orders α. All must be > 1. + * Finer grids give tighter bounds; this set covers the range relevant + * for typical ML workloads. + */ + private static final double[] ORDERS = { + 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024 + }; + + // ----------------------------------------------------------------------- + // State + // ----------------------------------------------------------------------- + + /** Accumulated Rényi divergence at each order (Gaussian releases only). */ + private final double[] _rdpSum = new double[ORDERS.length]; + + /** + * Running sum of pure ε from Laplace releases. + * + * Laplace gives pure ε-DP (no δ). Basic composition is exact and + * tighter than the RDP conversion path for Laplace (which would introduce + * an unnecessary δ and produce a looser bound). Each Laplace release adds + * its ε here; the total is added directly in {@link #totalEpsilonSpent()}. + */ + private double _pureEpsilonSum = 0.0; + + /** Total privacy budget (ε) for the script execution. */ + private final double _epsilonBudget; + + /** δ used for the Gaussian RDP-to-(ε,δ) conversion. */ + private final double _delta; + + /** Number of releases recorded so far (for error messages). */ + private int _releaseCount = 0; + + // ----------------------------------------------------------------------- + // Constructors + // ----------------------------------------------------------------------- + + /** + * Creates an accountant with the given global budget. + * + * Typical usage: the DML script sets the budget once at the top + * (future work: a {@code dp_set_budget(epsilon, delta)} built-in), + * or the accountant is created with defaults and the budget is checked + * after each release. + * + * @param epsilonBudget total ε budget for the script execution (must be > 0) + * @param delta δ used for the Gaussian RDP-to-(ε,δ) conversion (must be in (0,1)) + */ + public DPBudgetAccountant(double epsilonBudget, double delta) { + if (!(epsilonBudget > 0)) + throw new DMLRuntimeException( + "DPBudgetAccountant: epsilonBudget must be > 0, got " + epsilonBudget); + if (!(delta > 0 && delta < 1)) + throw new DMLRuntimeException( + "DPBudgetAccountant: delta must be in (0,1), got " + delta); + _epsilonBudget = epsilonBudget; + _delta = delta; + } + + /** + * Convenience constructor using a liberal default δ = 1e-5. + * Suitable when the calling script does not specify δ explicitly. + */ + public DPBudgetAccountant(double epsilonBudget) { + this(epsilonBudget, 1e-5); + } + + /** + * Default constructor using defaults. + * Suitable when the calling script does not specify ε, δ explicitly. + */ + public DPBudgetAccountant() { + this(DEFAULT_EPSILON_BUDGET, DEFAULT_DELTA); + } + + // ----------------------------------------------------------------------- + // Core API + // ----------------------------------------------------------------------- + + /** + * Records one DP release and checks the budget. + * + * This method must be called before the result is written to + * the variable table. If the budget is exhausted it throws and the + * caller's result is discarded, preventing an unaccounted release. + * + * Mechanism selection (see class-level Javadoc for details): + * - {@code delta == 0} → Laplace, pure ε-DP basic composition + * - {@code delta > 0} → Gaussian, Rényi DP composition + * + * @param epsilon per-release ε parameter (must be > 0) + * @param delta per-release δ parameter (0 for Laplace, >0 for Gaussian) + * @param sensitivity sensitivity Δf of the released quantity (must be > 0). + * The norm depends on the mechanism selected by + * {@code delta}: callers must supply the + * L1 sensitivity ‖f(D) − f(D′)‖₁ when + * {@code delta == 0} (Laplace), and the L2 + * sensitivity ‖f(D) − f(D′)‖₂ when {@code delta > 0} + * (Gaussian). The two coincide for scalar-valued + * releases but diverge for vector-valued ones, so + * passing the wrong norm silently under- or + * over-calibrates the noise. + * @throws DMLRuntimeException if the cumulative ε after this release + * would exceed the budget + */ + public void compose(double epsilon, double delta, double sensitivity) { + _releaseCount++; + + if (delta == 0.0) { + // Laplace: pure ε-DP, basic composition — cost is exactly epsilon. + _pureEpsilonSum += epsilon; + } else { + // Gaussian: accumulate Rényi divergence at each order, then convert. + for (int i = 0; i < ORDERS.length; i++) { + double sigma = gaussianSigma(sensitivity, epsilon, delta); + _rdpSum[i] += rdpGaussian(ORDERS[i], sensitivity, sigma); + } + } + + double spentEpsilon = totalEpsilonSpent(); + if (spentEpsilon > _epsilonBudget) { + throw new DMLRuntimeException(String.format( + "Privacy budget exhausted after %d release(s): " + + "spent ε ≈ %.6f exceeds budget ε = %.6f (δ = %.2e). " + + "Reduce the number of releases or widen the budget.", + _releaseCount, spentEpsilon, _epsilonBudget, _delta)); + } + } + + // ----------------------------------------------------------------------- + // Inspection + // ----------------------------------------------------------------------- + + /** + * Returns the current total privacy cost as an ε value. + * + * Total = Laplace pure-ε sum + Gaussian RDP-converted ε (clamped to + * zero when no Gaussian releases have been recorded). + */ + public double totalEpsilonSpent() { + // Take min_α(ε_α) as the current total privacy cost + double gaussianEps = Double.MAX_VALUE; + for (int i = 0; i < ORDERS.length; i++) { + double alpha = ORDERS[i]; + double eps = _rdpSum[i] + + Math.log(1.0 - 1.0 / alpha) + - Math.log(_delta * (alpha - 1.0)) / alpha; + if (eps < gaussianEps) + gaussianEps = eps; + } + // Clamp: with no Gaussian releases the RDP sum is 0 and the log-delta + // term alone drives gaussianEps to a small positive value; clamp to 0 + // so Laplace-only scripts are not penalised by δ they never requested. + if (gaussianEps < 0) gaussianEps = 0.0; + return _pureEpsilonSum + gaussianEps; + } + + /** Returns the remaining ε budget (negative if the budget is exceeded). */ + public double remainingBudget() { + return _epsilonBudget - totalEpsilonSpent(); + } + + /** Returns the number of DP releases recorded so far. */ + public int releaseCount() { + return _releaseCount; + } + + // ----------------------------------------------------------------------- + // Private helpers + // ----------------------------------------------------------------------- + + /** + * Rényi divergence of order α for the Gaussian mechanism (Mironov 2017, + * Proposition 3, example 2): + * D_α = α · Δf² / (2σ²) + */ + private static double rdpGaussian(double alpha, double sensitivity, double sigma) { + return alpha * (sensitivity * sensitivity) / (2.0 * sigma * sigma); + } + + /** + * Gaussian noise scale σ calibrated to (ε, δ)-DP: + * σ = Δf · sqrt(2 · log(1.25 / δ)) / ε + * Must match the formula used in {@link DPBuiltinCPInstruction} so that + * the RDP cost recorded here is consistent with the noise actually injected. + */ + private static double gaussianSigma(double sensitivity, double epsilon, double delta) { + return sensitivity * Math.sqrt(2.0 * Math.log(1.25 / delta)) / epsilon; + } +} diff --git a/src/main/python/requirements.txt b/src/main/python/requirements.txt new file mode 100644 index 00000000000..5d17a102c29 --- /dev/null +++ b/src/main/python/requirements.txt @@ -0,0 +1,31 @@ +#------------------------------------------------------------- +# +# 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. +# +#------------------------------------------------------------- + +numpy +pandas +scipy +py4j +wheel +requests +setuptools + +scikit-learn +matplotlib diff --git a/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java b/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java new file mode 100755 index 00000000000..c10dd065fd8 --- /dev/null +++ b/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java @@ -0,0 +1,418 @@ +/* + * 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. + */ + +package org.apache.sysds.test.component.cp; + +import org.apache.sysds.parser.DMLProgram; +import org.apache.sysds.runtime.DMLRuntimeException; +import org.apache.sysds.runtime.controlprogram.Program; +import org.apache.sysds.runtime.controlprogram.context.ExecutionContext; +import org.apache.sysds.runtime.controlprogram.context.ExecutionContextFactory; +import org.apache.sysds.runtime.privacy.dp.DPBudgetAccountant; +import org.junit.Test; + +import static org.junit.Assert.*; + +/** + * Tests for {@code DPBuiltinCPInstruction} and {@code DPBudgetAccountant}. + * + * The tests are grouped into three levels: + * - Unit tests on DPBudgetAccountant — verify composition, conversion, + * and budget enforcement in isolation, with no dependency on the full + * SystemDS runtime. + * - Noise distribution tests — verify that the noise blocks + * generated by the Laplace and Gaussian mechanisms have statistically + * correct means and variances (Kolmogorov-Smirnov style sanity checks). + * - DML integration tests — run complete DML scripts and verify + * end-to-end correctness via the existing AutomatedTestBase machinery. + * + * The DML integration tests require a built SystemDS jar and are separated + * into a companion class {@link org.apache.sysds.test.functions.privacy.dp.DPBuiltinDMLTest}. + */ +public class DPBuiltinCPInstructionTest { + + private static final double EPS = 1e-9; + + // ======================================================================= + // 1. DPBudgetAccountant unit tests + // ======================================================================= + + @Test + public void testAccountantInitialisesAtZeroCost() { + DPBudgetAccountant acc = new DPBudgetAccountant(1.0, 1e-5); + // No releases yet: total cost should be a large negative number + // (conversion formula gives -∞ when rdpSum = 0 for all orders), + // so remainingBudget() should exceed the budget. + assertTrue("No releases should leave budget intact", + acc.remainingBudget() > 0); + assertEquals(0, acc.releaseCount()); + } + + @Test + public void testSingleLaplaceReleaseDoesNotExceedBudget() { + // epsilon=0.5, budget=1.0: one release should consume < budget. + DPBudgetAccountant acc = new DPBudgetAccountant(1.0, 1e-5); + acc.compose(0.5, 0.0, 1.0); // Laplace, sensitivity=1 + assertEquals(1, acc.releaseCount()); + assertTrue("Single release within budget", + acc.totalEpsilonSpent() <= 1.0); + } + + @Test + public void testSingleGaussianReleaseDoesNotExceedBudget() { + DPBudgetAccountant acc = new DPBudgetAccountant(1.0, 1e-5); + acc.compose(0.5, 1e-5, 1.0); // Gaussian + assertEquals(1, acc.releaseCount()); + assertTrue("Single Gaussian release within budget", + acc.totalEpsilonSpent() <= 1.0); + } + + @Test(expected = DMLRuntimeException.class) + public void testBudgetExhaustionThrows() { + // Budget = 0.1, but we try to make 10 releases at epsilon=0.5 each. + // After enough releases the budget must be exceeded. + DPBudgetAccountant acc = new DPBudgetAccountant(0.1, 1e-5); + for (int i = 0; i < 10; i++) { + acc.compose(0.5, 0.0, 1.0); // will throw before the 10th + } + } + + @Test + public void testCompositionIsMonotonicallyIncreasing() { + DPBudgetAccountant acc = new DPBudgetAccountant(100.0, 1e-5); // large budget + double prev = acc.totalEpsilonSpent(); + for (int i = 0; i < 5; i++) { + acc.compose(0.3, 1e-5, 1.0); + double current = acc.totalEpsilonSpent(); + assertTrue("Epsilon spent must increase with each release", + current > prev); + prev = current; + } + } + + @Test + public void testGaussianTighterThanLaplaceForSameEpsilon() { + // For the same nominal (ε, δ), Gaussian uses RDP composition which + // is tighter than Laplace with basic composition. After 5 releases: + // Laplace (basic, worst-case): 5ε + // Gaussian (RDP) : something < 5ε + double eps = 0.5; + double delta = 1e-5; + + DPBudgetAccountant gaussian = new DPBudgetAccountant(100.0, delta); + DPBudgetAccountant laplace = new DPBudgetAccountant(100.0, delta); + + for (int i = 0; i < 5; i++) { + gaussian.compose(eps, delta, 1.0); + laplace.compose(eps, 0.0, 1.0); + } + + // After 5 releases, Gaussian RDP bound should be tighter. + // (Both may be < 5*eps; the point is Gaussian <= Laplace.) + assertTrue("Gaussian RDP bound should be <= Laplace bound after 5 releases", + gaussian.totalEpsilonSpent() <= laplace.totalEpsilonSpent() + 1e-6); + } + + @Test + public void testRemainingBudgetDecreasesMonotonically() { + DPBudgetAccountant acc = new DPBudgetAccountant(2.0, 1e-5); + double prev = acc.remainingBudget(); + for (int i = 0; i < 3; i++) { + acc.compose(0.2, 1e-5, 1.0); + double current = acc.remainingBudget(); + assertTrue("Remaining budget must decrease", current < prev); + prev = current; + } + } + + @Test + public void testHigherEpsilonCostMoreForLaplace() { + // For Laplace, the accountant uses basic (pure ε-DP) composition: cost = epsilon. + // Sensitivity determines noise scale but NOT the budget consumed — that is set + // entirely by the caller's epsilon parameter. + // A release at epsilon=1.0 costs more budget than one at epsilon=0.5. + DPBudgetAccountant acc1 = new DPBudgetAccountant(100.0, 1e-5); + DPBudgetAccountant acc2 = new DPBudgetAccountant(100.0, 1e-5); + acc1.compose(0.5, 0.0, 1.0); // epsilon=0.5, Laplace + acc2.compose(1.0, 0.0, 1.0); // epsilon=1.0, same sensitivity + + assertTrue("Higher epsilon costs more budget (Laplace basic composition)", + acc1.totalEpsilonSpent() < acc2.totalEpsilonSpent()); + } + + // --- Constructor error paths ------------------------------------ + + @Test(expected = DMLRuntimeException.class) + public void testConstructorRejectsZeroEpsilonBudget() { + new DPBudgetAccountant(0.0, 1e-5); + } + + @Test(expected = DMLRuntimeException.class) + public void testConstructorRejectsNegativeEpsilonBudget() { + new DPBudgetAccountant(-0.5, 1e-5); + } + + @Test(expected = DMLRuntimeException.class) + public void testConstructorRejectsDeltaZero() { + new DPBudgetAccountant(1.0, 0.0); + } + + @Test(expected = DMLRuntimeException.class) + public void testConstructorRejectsDeltaOne() { + new DPBudgetAccountant(1.0, 1.0); + } + + // ======================================================================= + // 1b. DMLProgram / ExecutionContext.getDPBudgetAccountant() (dp_set_budget) + // ======================================================================= + // + // dp_set_budget(epsilon, delta) is resolved entirely at compile time onto + // DMLProgram (DMLTranslator's DP_SET_BUDGET case) rather than through a + // runtime instruction; ExecutionContext.getDPBudgetAccountant() consults + // Program.getDMLProg() on first (lazy) access. These tests exercise that + // plumbing directly, without going through the DML compiler. + + @Test + public void testDMLProgramHasDPBudgetTracksSetState() { + DMLProgram dmlProg = new DMLProgram(); + assertFalse("No dp_set_budget call yet", dmlProg.hasDPBudget()); + dmlProg.setDPBudget(2.0, 1e-6); + assertTrue("dp_set_budget was called", dmlProg.hasDPBudget()); + assertEquals(2.0, dmlProg.getDPBudgetEpsilon(), EPS); + assertEquals(1e-6, dmlProg.getDPBudgetDelta(), EPS); + } + + @Test + public void testGetDPBudgetAccountantUsesCompileTimeResolvedBudget() { + DMLProgram dmlProg = new DMLProgram(); + dmlProg.setDPBudget(5.0, 1e-6); + ExecutionContext ec = ExecutionContextFactory.createContext(new Program(dmlProg)); + + DPBudgetAccountant acc = ec.getDPBudgetAccountant(); + acc.compose(2.0, 0.0, 1.0); // would exceed the hardcoded default budget of 1.0 + assertTrue("Compile-time-resolved budget should be used instead of the hardcoded default", + acc.remainingBudget() > 0); + } + + @Test(expected = DMLRuntimeException.class) + public void testGetDPBudgetAccountantFallsBackToDefaultWithoutDPSetBudget() { + // No dp_set_budget call: the hardcoded default budget of epsilon=1.0 applies, + // so a release at epsilon=1.5 must be rejected. + DMLProgram dmlProg = new DMLProgram(); + ExecutionContext ec = ExecutionContextFactory.createContext(new Program(dmlProg)); + ec.getDPBudgetAccountant().compose(1.5, 0.0, 1.0); + } + + @Test + public void testGetDPBudgetAccountantIsLazyAndCachedPerContext() { + // The accountant must be created once and reused across calls on the + // same ExecutionContext, not rebuilt (which would reset releaseCount()). + DMLProgram dmlProg = new DMLProgram(); + dmlProg.setDPBudget(10.0, 1e-6); + ExecutionContext ec = ExecutionContextFactory.createContext(new Program(dmlProg)); + + ec.getDPBudgetAccountant().compose(1.0, 0.0, 1.0); + assertEquals("Same accountant instance must be reused across calls", + 1, ec.getDPBudgetAccountant().releaseCount()); + } + + // --- Single-argument convenience constructor ------------------- + + @Test + public void testConvenienceConstructorDefaultsDeltaTo1e5() { + // The one-arg form delegates to (epsilonBudget, 1e-5). A Gaussian + // release whose per-release delta matches that default must produce + // identical totalEpsilonSpent() from both construction paths. + DPBudgetAccountant oneArg = new DPBudgetAccountant(10.0); + DPBudgetAccountant twoArg = new DPBudgetAccountant(10.0, 1e-5); + oneArg.compose(0.5, 1e-5, 1.0); + twoArg.compose(0.5, 1e-5, 1.0); + assertEquals("Convenience constructor must default to delta=1e-5", + twoArg.totalEpsilonSpent(), oneArg.totalEpsilonSpent(), EPS); + } + + @Test(expected = DMLRuntimeException.class) + public void testGaussianBudgetExhaustionThrows() { + // Budget = 0.1. Each Gaussian release costs more than 0.005, so 20 + // releases must exceed the budget well before the loop ends. + DPBudgetAccountant acc = new DPBudgetAccountant(0.1, 1e-5); + for (int i = 0; i < 20; i++) { + acc.compose(0.3, 1e-5, 1.0); + } + } + + @Test + public void testMixedCompositionExceedsEitherAlone() { + // Compose one Laplace and one Gaussian release. The total cost must + // exceed what either mechanism contributes alone, exercising the + // _pureEpsilonSum + gaussianEps addition path in totalEpsilonSpent(). + DPBudgetAccountant mixed = new DPBudgetAccountant(100.0, 1e-5); + DPBudgetAccountant lapOnly = new DPBudgetAccountant(100.0, 1e-5); + DPBudgetAccountant gauOnly = new DPBudgetAccountant(100.0, 1e-5); + + mixed.compose(0.5, 0.0, 1.0); // Laplace + mixed.compose(0.5, 1e-5, 1.0); // Gaussian + + lapOnly.compose(0.5, 0.0, 1.0); + gauOnly.compose(0.5, 1e-5, 1.0); + + assertTrue("Mixed cost must exceed Laplace-only cost", + mixed.totalEpsilonSpent() > lapOnly.totalEpsilonSpent()); + assertTrue("Mixed cost must exceed Gaussian-only cost", + mixed.totalEpsilonSpent() > gauOnly.totalEpsilonSpent()); + } + + // --- Release count across multiple mixed releases -------------- + + @Test + public void testReleaseCountTracksAllReleases() { + DPBudgetAccountant acc = new DPBudgetAccountant(100.0, 1e-5); + assertEquals(0, acc.releaseCount()); + acc.compose(0.1, 0.0, 1.0); // Laplace + assertEquals(1, acc.releaseCount()); + acc.compose(0.1, 1e-5, 1.0); // Gaussian + assertEquals(2, acc.releaseCount()); + acc.compose(0.1, 0.0, 1.0); // Laplace + acc.compose(0.1, 0.0, 1.0); // Laplace + acc.compose(0.1, 1e-5, 1.0); // Gaussian + assertEquals(5, acc.releaseCount()); + } + + // --- Edge-case inputs for rdpGaussian / gaussianSigma ---------- + + @Test + public void testGaussianSensitivityCancelsInRDP() { + // For the Gaussian mechanism: σ = Δf·sqrt(2·ln(1.25/δ))/ε, so + // D_α = α·Δf²/(2σ²) = α·ε²/(4·ln(1.25/δ)). + // Sensitivity cancels. Two accountants with the same (ε,δ) but + // different sensitivity must report identical totalEpsilonSpent(). + DPBudgetAccountant acc1 = new DPBudgetAccountant(100.0, 1e-5); + DPBudgetAccountant acc2 = new DPBudgetAccountant(100.0, 1e-5); + acc1.compose(0.5, 1e-5, 1.0); // sensitivity = 1 + acc2.compose(0.5, 1e-5, 100.0); // sensitivity = 100, same (ε,δ) + assertEquals("Gaussian RDP cost must be independent of sensitivity when (ε,δ) are fixed", + acc1.totalEpsilonSpent(), acc2.totalEpsilonSpent(), EPS); + } + + @Test + public void testGaussianLargerEpsilonCostsMoreBudget() { + // D_α ∝ ε², so a release declared at a higher ε (less noise, more + // privacy loss) must cost more budget than one at a lower ε. + DPBudgetAccountant lowEps = new DPBudgetAccountant(100.0, 1e-5); + DPBudgetAccountant highEps = new DPBudgetAccountant(100.0, 1e-5); + lowEps.compose(0.1, 1e-5, 1.0); + highEps.compose(0.5, 1e-5, 1.0); + assertTrue("Larger epsilon per Gaussian release must cost more budget", + highEps.totalEpsilonSpent() > lowEps.totalEpsilonSpent()); + } + + // ======================================================================= + // 2. Noise distribution tests (statistical sanity checks) + // ======================================================================= + // These tests generate many samples and verify that the empirical mean + // is near zero and the empirical variance matches the theoretical value + // within a reasonable tolerance. + // + + @Test + public void testLaplaceNoiseMeanNearZero() { + // For 10000 samples the empirical mean should be within 3σ/√n of 0. + int n = 10_000; + double scale = 2.0; + double[] samples = sampleLaplace(n, scale); + double mean = mean(samples); + double theoreticalStdErr = scale * Math.sqrt(2.0) / Math.sqrt(n); + assertTrue("Laplace mean should be near 0", + Math.abs(mean) < 5 * theoreticalStdErr); + } + + @Test + public void testLaplaceNoiseVarianceCorrect() { + // Var[Laplace(0, b)] = 2b². Allow 10% relative error for n=10000. + int n = 10_000; + double scale = 1.5; + double[] samples = sampleLaplace(n, scale); + double variance = variance(samples); + double expected = 2.0 * scale * scale; + assertEquals("Laplace variance", expected, variance, 0.1 * expected); + } + + @Test + public void testGaussianNoiseMeanNearZero() { + int n = 10_000; + double sigma = 3.0; + double[] samples = sampleGaussian(n, sigma); + double mean = mean(samples); + double theoreticalStdErr = sigma / Math.sqrt(n); + assertTrue("Gaussian mean should be near 0", + Math.abs(mean) < 5 * theoreticalStdErr); + } + + @Test + public void testGaussianNoiseVarianceCorrect() { + int n = 10_000; + double sigma = 2.0; + double[] samples = sampleGaussian(n, sigma); + double variance = variance(samples); + double expected = sigma * sigma; + assertEquals("Gaussian variance", expected, variance, 0.1 * expected); + } + + // ----------------------------------------------------------------------- + // Helpers for noise distribution tests + // ----------------------------------------------------------------------- + + /** Sample n Laplace(0, scale) values using the inverse-CDF method. */ + private static double[] sampleLaplace(int n, double scale) { + java.util.concurrent.ThreadLocalRandom rng = + java.util.concurrent.ThreadLocalRandom.current(); + double[] out = new double[n]; + for (int i = 0; i < n; i++) { + double u = rng.nextDouble(); + double v = u - 0.5; + if (v == 0.0) v = 1e-15; + out[i] = -scale * Math.signum(v) * Math.log(1.0 - 2.0 * Math.abs(v)); + } + return out; + } + + /** Sample n N(0, sigma²) values. */ + private static double[] sampleGaussian(int n, double sigma) { + java.util.concurrent.ThreadLocalRandom rng = + java.util.concurrent.ThreadLocalRandom.current(); + double[] out = new double[n]; + for (int i = 0; i < n; i++) { + out[i] = sigma * rng.nextGaussian(); + } + return out; + } + + private static double mean(double[] xs) { + double s = 0; + for (double x : xs) s += x; + return s / xs.length; + } + + private static double variance(double[] xs) { + double m = mean(xs); + double s = 0; + for (double x : xs) s += (x - m) * (x - m); + return s / (xs.length - 1); + } +} diff --git a/src/test/java/org/apache/sysds/test/functions/privacy/dp/DPBuiltinDMLTest.java b/src/test/java/org/apache/sysds/test/functions/privacy/dp/DPBuiltinDMLTest.java new file mode 100644 index 00000000000..9b2ef30e0af --- /dev/null +++ b/src/test/java/org/apache/sysds/test/functions/privacy/dp/DPBuiltinDMLTest.java @@ -0,0 +1,260 @@ +// ========================================================================== +// DML integration test +// ========================================================================== +// +// Full integration tests extend AutomatedTestBase and drive the DML runner. +// Each test: +// (a) Writes a DML script to a temp file. +// (b) Provides input matrices via TestUtils. +// (c) Calls runTest() and reads the output MatrixBlock. +// (d) Verifies that the noisy result differs from the clean result by a +// statistically plausible amount (not zero, not astronomically large). +// + + +package org.apache.sysds.test.functions.privacy.dp; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.HashMap; + +import org.apache.sysds.parser.LanguageException; +import org.apache.sysds.parser.ParseException; +import org.apache.sysds.runtime.DMLRuntimeException; +import org.apache.sysds.runtime.matrix.data.MatrixValue.CellIndex; +import org.apache.sysds.test.AutomatedTestBase; +import org.apache.sysds.test.TestConfiguration; +import org.apache.sysds.test.TestUtils; +import org.junit.Test; + +public class DPBuiltinDMLTest extends AutomatedTestBase { + + private static final String TEST_DIR = "functions/privacy/dp/"; + private static final String TEST_CLASS = TEST_DIR + DPBuiltinDMLTest.class.getSimpleName() + "/"; + private static final int ROWS = 100; + private static final int COLS = 10; + + private static final String DML_LAPLACE_TEMPLATE = + "X = read($1);\n" + + "result = dp_laplace(X, query=\"%s\", sensitivity=1.0, epsilon=$2);\n" + + "write(result, $3, format=\"text\");\n"; + private static final String DML_GAUSSIAN_TEMPLATE = + "X = read($1);\n" + + "result = dp_gaussian(X, query=\"%s\", sensitivity=1.0, epsilon=$2, delta=1e-5);\n" + + "write(result, $3, format=\"text\");\n"; + + private static final String DML_LAPLACE = String.format(DML_LAPLACE_TEMPLATE, "colMeans"); + private static final String DML_GAUSSIAN = String.format(DML_GAUSSIAN_TEMPLATE, "colMeans"); + + // dp_set_budget(epsilon, delta) is resolved entirely at compile time (its arguments + // must be literals), called via a dummy assignment, then a single dp_laplace release + // at $2 records a cost of exactly $2 (Laplace basic composition). + private static final String DML_SET_BUDGET_TEMPLATE = + "eps = dp_set_budget(%s, 1e-6);\n" + + "X = read($1);\n" + + "result = dp_laplace(X, query=\"colMeans\", sensitivity=1.0, epsilon=$2);\n" + + "write(result, $3, format=\"text\");\n"; + + // Two dp_set_budget calls in the same script; DMLTranslator must reject this at + // compile time (DMLProgram.hasDPBudget()). + private static final String DML_SET_BUDGET_TWICE = + "eps = dp_set_budget(3.0, 1e-6);\n" + + "eps2 = dp_set_budget(5.0, 1e-6);\n" + + "X = read($1);\n" + + "result = dp_laplace(X, query=\"colMeans\", sensitivity=1.0, epsilon=$2);\n" + + "write(result, $3, format=\"text\");\n"; + + // A budget argument computed at runtime (not a literal); must be rejected at + // compile time by BuiltinFunctionExpression's isConstant() check. + private static final String DML_SET_BUDGET_NON_LITERAL = + "X = read($1);\n" + + "computed = sum(X) / nrow(X);\n" + + "eps = dp_set_budget(computed, 1e-6);\n" + + "result = dp_laplace(X, query=\"colMeans\", sensitivity=1.0, epsilon=$2);\n" + + "write(result, $3, format=\"text\");\n"; + + @Override + public void setUp() { + addTestConfiguration("DPLaplace", new TestConfiguration(TEST_CLASS, "DPLaplace")); + addTestConfiguration("DPGaussian", new TestConfiguration(TEST_CLASS, "DPGaussian")); + addTestConfiguration("DPSetBudget", new TestConfiguration(TEST_CLASS, "DPSetBudget")); + } + + @Test + public void testLaplaceOutputDiffersFromCleanMean() { + runColMeansDPTest("DPLaplace", DML_LAPLACE, "0.5"); + } + + @Test + public void testGaussianOutputDiffersFromCleanMean() { + runColMeansDPTest("DPGaussian", DML_GAUSSIAN, "0.5"); + } + + @Test + public void testLaplaceColSums() { + // query="colSums": T is 1 x n filled with 1.0, output is the noisy column-sum row vector. + double[][] data = TestUtils.generateTestMatrix(ROWS, COLS, 0, 1, 1.0, 42); + HashMap result = runAndGetResult("DPLaplace", + String.format(DML_LAPLACE_TEMPLATE, "colSums"), "0.5", data); + assertShape(result, 1, COLS); + double maxDiff = maxAbsDiffFromClean(data, result, DPBuiltinDMLTest::colSum); + assertTrue("Result should differ from the clean column sums", maxDiff > 0); + } + + @Test + public void testGaussianIdentity() { + // query="identity": T is the n x n identity, output is a noisy release of X itself. + double[][] data = TestUtils.generateTestMatrix(ROWS, COLS, 0, 1, 1.0, 42); + HashMap result = runAndGetResult("DPGaussian", + String.format(DML_GAUSSIAN_TEMPLATE, "identity"), "0.5", data); + assertShape(result, ROWS, COLS); + // identity releases X row-by-row, so compare cell-by-cell rather than via a per-column reduction. + double maxCellDiff = 0; + for (int r = 0; r < ROWS; r++) { + for (int c = 0; c < COLS; c++) { + double noisy = result.get(new CellIndex(r + 1, c + 1)); + maxCellDiff = Math.max(maxCellDiff, Math.abs(noisy - data[r][c])); + } + } + assertTrue("Result should differ from the clean matrix", maxCellDiff > 0); + } + + @Test + public void testHighEpsilonIsCloserToTruth() { + double[][] data = TestUtils.generateTestMatrix(ROWS, COLS, 0, 1, 1.0, 42); + // Higher ε → less noise → result closer to the true mean. + // NOTE: the DPBudgetAccountant caps total spend at the default budget + // (ε = 1.0) regardless of the per-release ε requested, so ε values + // here must stay well under that cap or the release is rejected. + double noisyLow = runAndGetMaxAbsColMeansDiffFromClean(data, "DPGaussian", DML_GAUSSIAN, "0.1"); + double noisyHigh = runAndGetMaxAbsColMeansDiffFromClean(data, "DPGaussian", DML_GAUSSIAN, "0.5"); + assertTrue("ε=0.5 should give less noise than ε=0.1", noisyHigh < noisyLow); + } + + @Test + public void testSetBudgetLiteralAllowsExceedingDefaultBudget() { + // Default budget is epsilon=1.0; a single release at epsilon=1.5 would be + // rejected unless dp_set_budget(3.0, ...) widens it first. + double[][] data = TestUtils.generateTestMatrix(ROWS, COLS, 0, 1, 1.0, 42); + HashMap result = runAndGetResult("DPSetBudget", + String.format(DML_SET_BUDGET_TEMPLATE, "3.0"), "1.5", data); + assertShape(result, 1, COLS); + } + + @Test + public void testSetBudgetNarrowBudgetStillEnforced() { + // An explicit narrow budget must still be enforced: epsilon=0.8 exceeds + // the explicit budget of 0.5. + double[][] data = TestUtils.generateTestMatrix(ROWS, COLS, 0, 1, 1.0, 42); + runExpectingException("DPSetBudget", String.format(DML_SET_BUDGET_TEMPLATE, "0.5"), "0.8", data, + DMLRuntimeException.class); + } + + @Test + public void testSetBudgetCalledTwiceFailsAtCompileTime() { + // Thrown from DMLTranslator.processBuiltinFunctionExpression (HOP construction), + // which wraps all case-block exceptions in ParseException (see processExpression's + // catch-all) — unlike the non-literal check below, which runs during validation + // and so surfaces as a bare LanguageException. + double[][] data = TestUtils.generateTestMatrix(ROWS, COLS, 0, 1, 1.0, 42); + runExpectingException("DPSetBudget", DML_SET_BUDGET_TWICE, "0.5", data, ParseException.class); + } + + @Test + public void testSetBudgetRejectsNonLiteralArgs() { + double[][] data = TestUtils.generateTestMatrix(ROWS, COLS, 0, 1, 1.0, 42); + runExpectingException("DPSetBudget", DML_SET_BUDGET_NON_LITERAL, "0.5", data, LanguageException.class); + } + + private void runColMeansDPTest(String testName, String dml, String epsilonStr) { + double[][] data = TestUtils.generateTestMatrix(ROWS, COLS, 0, 1, 1.0, 42); + HashMap result = runAndGetResult(testName, dml, epsilonStr, data); + assertShape(result, 1, COLS); + // Must differ from the exact (clean) mean by a non-trivial amount. + // (A single-seed exact-equality check is fragile; use range check.) + double maxDiff = maxAbsDiffFromClean(data, result, DPBuiltinDMLTest::colMean); + assertTrue("Result should differ from the clean mean", maxDiff > 0); + } + + private double runAndGetMaxAbsColMeansDiffFromClean(double[][] data, String testName, String dml, String epsilonStr) { + HashMap result = runAndGetResult(testName, dml, epsilonStr, data); + return maxAbsDiffFromClean(data, result, DPBuiltinDMLTest::colMean); + } + + private static void assertShape(HashMap result, int expectedRows, int expectedCols) { + int maxRow = 0, maxCol = 0; + for (CellIndex ci : result.keySet()) { + maxRow = Math.max(maxRow, ci.row); + maxCol = Math.max(maxCol, ci.column); + } + assertEquals("Result should have " + expectedRows + " row(s)", expectedRows, maxRow); + assertEquals("Result should have " + expectedCols + " column(s)", expectedCols, maxCol); + } + + @FunctionalInterface + private interface CleanColumnFn { + double apply(double[][] data, int col); + } + + /** Computes max|noisy(1,c) - clean(data,c)| across the (1 x COLS) row-vector releases. */ + private static double maxAbsDiffFromClean(double[][] data, HashMap result, + CleanColumnFn cleanFn) { + double maxDiff = 0; + for (int c = 0; c < COLS; c++) { + double clean = cleanFn.apply(data, c); + double noisy = result.get(new CellIndex(1, c + 1)); + maxDiff = Math.max(maxDiff, Math.abs(noisy - clean)); + } + return maxDiff; + } + + private static double colMean(double[][] data, int c) { + double sum = 0; + for (int r = 0; r < ROWS; r++) + sum += data[r][c]; + return sum / ROWS; + } + + private static double colSum(double[][] data, int c) { + double sum = 0; + for (int r = 0; r < ROWS; r++) + sum += data[r][c]; + return sum; + } + + private HashMap runAndGetResult(String testName, String dml, String epsilonStr, + double[][] data) + { + prepareScript(testName, dml, epsilonStr, data); + runTest(true, false, null, -1); + return readDMLMatrixFromOutputDir("result"); + } + + private void runExpectingException(String testName, String dml, String epsilonStr, double[][] data, + Class expectedException) + { + prepareScript(testName, dml, epsilonStr, data); + runTest(true, true, expectedException, -1); + } + + private void prepareScript(String testName, String dml, String epsilonStr, double[][] data) { + getAndLoadTestConfiguration(testName); + writeInputMatrixWithMTD("X", data, false); + + fullDMLScriptName = getScript(); + try { + File scriptFile = new File(fullDMLScriptName); + scriptFile.getParentFile().mkdirs(); + Files.write(scriptFile.toPath(), dml.getBytes()); + } + catch (IOException e) { + throw new RuntimeException(e); + } + + programArgs = new String[]{ "-args", input("X"), epsilonStr, output("result") }; + } +}