From 25673e92082d1cc0a88d8aae97decacb9853f8a2 Mon Sep 17 00:00:00 2001 From: Maya Anderson Date: Fri, 26 Jun 2026 20:20:31 +0200 Subject: [PATCH 01/43] Initial DPBuiltinCPInstruction and RDPAccountant --- .../cp/DPBuiltinCPInstruction.java | 349 +++++++++++++++++ .../runtime/privacy/dp/RDPAccountant.java | 283 ++++++++++++++ .../cp/DPBuiltinCPInstructionTest.java | 350 ++++++++++++++++++ 3 files changed, 982 insertions(+) create mode 100755 src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinCPInstruction.java create mode 100755 src/main/java/org/apache/sysds/runtime/privacy/dp/RDPAccountant.java create mode 100755 src/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java 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..f75ccb13cdb --- /dev/null +++ b/src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinCPInstruction.java @@ -0,0 +1,349 @@ +/* + * 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.functionobjects.Plus; +import org.apache.sysds.runtime.instructions.InstructionUtils; +import org.apache.sysds.runtime.matrix.data.MatrixBlock; +import org.apache.sysds.runtime.matrix.operators.BinaryOperator; +import org.apache.sysds.runtime.privacy.dp.RDPAccountant; + +import java.util.LinkedHashMap; +import java.util.concurrent.ThreadLocalRandom; + +/** + * CP instruction for differential-privacy release of an already-computed + * aggregate. + * + *

DML syntax (post-aggregate form, Option A): + *

+ *   result = dp_laplace(aggregate, sensitivity=1.0, epsilon=0.5)
+ *   result = dp_gaussian(aggregate, sensitivity=1.0, epsilon=0.5, delta=1e-5)
+ * 
+ * + *

The instruction receives a materialised matrix (the aggregate result), + * injects calibrated noise element-wise, records the release with the + * session-scoped {@link RDPAccountant}, and returns the noisy matrix. + * + *

Noise is generated in Java and added via a {@code MatrixBlock} binary + * operation so that the output allocation path is identical to every other + * CP instruction (no special memory-management required). + * + *

The {@link #sensitivityOf} method is deliberately separated from the + * noise-scale computation. In Phase 1 it returns the caller-supplied + * constant. In the future HOP-level rewrite pass (Phase 2) the body of this + * single method is replaced with a static analysis that reads the + * sensitivity bound computed by the compiler; every other line in this class + * stays unchanged. + * + *

Registration required in: + *

+ */ +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", "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) { + // input1 = the aggregate matrix; input2/3 unused at this level + // (scalars come from _params, not CPOperand fields, so that they + // can be either literals or DML variable names transparently). + super(null, input, null, 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 (INSTRUCTION_DELIM = '\u00b0'): + *

+     *   dp_gaussian°target=mVar1·MATRIX·FP64°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, 4, 5); // laplace=4, gaussian=5 + 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("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. + * + *
    + *
  1. Read the aggregate {@link MatrixBlock} from the variable table.
  2. + *
  3. Determine sensitivity via {@link #sensitivityOf} (Phase-1 stub).
  4. + *
  5. Generate a noise {@link MatrixBlock} of the same shape.
  6. + *
  7. Add noise element-wise using the existing binary-operator path.
  8. + *
  9. Record the release with the session-scoped + * {@link RDPAccountant}; throw if budget is exhausted.
  10. + *
  11. Write the noisy block back to the variable table and release + * the input pin.
  12. + *
+ */ + @Override + public void processInstruction(ExecutionContext ec) { + + // ── 1. Read aggregate input ───────────────────────────────────────── + // getMatrixInput pins the block in memory and increments the + // reference count; we must call releaseMatrixInput afterwards. + MatrixBlock inBlock = ec.getMatrixInput(_params.get("target")); + + // ── 2. Parse DP parameters ────────────────────────────────────────── + double epsilon = parsePositiveDouble("epsilon"); + double delta = instOpcode.equals(OPCODE_GAUSSIAN) + ? parsePositiveDouble("delta") : 0.0; + + // ── 3. Determine sensitivity (Phase-1: caller-supplied constant) ──── + double sensitivity = sensitivityOf(inBlock); + + // ── 4. Generate and add noise ──────────────────────────────────────── + MatrixBlock noiseBlock = generateNoise(inBlock, sensitivity, epsilon, delta); + + // Element-wise addition via the standard binary-operator path. + // binaryOperations allocates the output block internally. + BinaryOperator plusOp = new BinaryOperator(Plus.getPlusFnObject()); + MatrixBlock outBlock = new MatrixBlock(); + inBlock.binaryOperations(plusOp, noiseBlock, outBlock); + + // ── 5. Record release and enforce budget ──────────────────────────── + // getRDPAccountant() returns a lazy-initialised RDPAccountant that is + // owned by this ExecutionContext (added in a companion EC patch). + RDPAccountant accountant = ec.getRDPAccountant(); + accountant.compose(epsilon, delta, sensitivity); // throws on exhaustion + + // ── 6. Write output and release input pin ─────────────────────────── + ec.releaseMatrixInput(_params.get("target")); + ec.setMatrixOutput(output.getName(), outBlock); + } + + // ----------------------------------------------------------------------- + // Sensitivity seam (Phase-1 stub; Phase-2 replaces this body only) + // ----------------------------------------------------------------------- + + /** + * Returns the sensitivity of {@code aggregate} to a single-record change. + * + *

Phase 1 (now): returns the caller-supplied literal from the + * DML script. Sensitivity analysis is the caller's responsibility. + * + *

Phase 2 (HOP-level rewrite pass): replace this body with a + * call that inspects the HOP node that produced {@code aggregate}, reads + * the {@code sensitivityBound} field computed during compilation, and + * returns it. No other line in this class changes. + * + * @param aggregate the already-computed aggregate block (ignored in + * Phase 1; used in Phase 2 to look up lineage) + * @return caller-supplied sensitivity constant + */ + private double sensitivityOf(MatrixBlock aggregate) { + // Phase 1: unwrap the literal or variable value from the param map. + // In Phase 2, replace the body below with HOP-annotation lookup. + return parsePositiveDouble("sensitivity"); + } + + // ----------------------------------------------------------------------- + // Noise generation + // ----------------------------------------------------------------------- + + /** + * Generates a noise {@link MatrixBlock} of the same shape as + * {@code aggregate}, 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 aggregate outputs targeted here (e.g. + * column means, row sums) the aggregate is already dense. + */ + private MatrixBlock generateNoise( + MatrixBlock aggregate, + double sensitivity, + double epsilon, + double delta) { + + int rows = aggregate.getNumRows(); + int cols = aggregate.getNumColumns(); + MatrixBlock noise = new MatrixBlock(rows, cols, false); // dense + noise.allocateDenseBlock(); + + if (instOpcode.equals(OPCODE_LAPLACE)) { + fillLaplaceNoise(noise, sensitivity / epsilon); + } else { + // Gaussian mechanism: calibrate sigma for (epsilon, delta)-DP. + // Standard formula: sigma >= sensitivity * sqrt(2 * ln(1.25/delta)) / epsilon + 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/RDPAccountant.java b/src/main/java/org/apache/sysds/runtime/privacy/dp/RDPAccountant.java new file mode 100755 index 00000000000..884012e4ee6 --- /dev/null +++ b/src/main/java/org/apache/sysds/runtime/privacy/dp/RDPAccountant.java @@ -0,0 +1,283 @@ +/* + * 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; + +/** + * Session-scoped Rényi Differential Privacy (RDP) budget accountant. + * + *

Purpose

+ * 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. + * + *

Why Rényi DP?

+ * Basic composition adds epsilons linearly, giving very loose bounds with + * many releases. Rényi DP divergences compose additively at the + * same order α. Converting the running Rényi sum to (ε, δ) via the standard + * conversion formula yields substantially tighter bounds — particularly for + * Gaussian mechanisms, which are common in federated learning. + * + *

Orders tracked

+ * We track a discrete set of Rényi orders α ∈ {2, 4, 8, 16, 32, 64, 128, + * 256, 512, 1024}. At query time we take the minimum converted ε across all + * orders, which is the tightest available bound. + * + *

Composition rules

+ * For the Gaussian mechanism with noise scale σ and sensitivity Δf, the + * Rényi divergence of order α between outputs on neighbouring datasets is: + *
+ *   D_α = α · Δf² / (2σ²)
+ * 
+ * where σ is back-derived from the caller's (ε, δ) parameters via the + * standard calibration formula. See {@link #rdpGaussian} for details. + * + * For the Laplace mechanism with scale b = Δf/ε, the Rényi divergence at + * order α is: + *
+ *   D_α = (1/(α-1)) · ln( α/(2α-1) · exp((α-1)/b) + (α-1)/(2α-1) · exp(-α/b) )
+ *       (for α > 1; the limit as α → 1 is 1/b, i.e. the KL divergence)
+ * 
+ * + *

Conversion: Rényi DP → (ε, δ)-DP

+ * Given accumulated Rényi divergence R[α] at order α and a target δ: + *
+ *   ε(α) = R[α] + log(1 - 1/α) - log(δ · (α - 1)) / α
+ * 
+ * The reported total cost is min_α ε(α). + * + *

Lifecycle

+ * 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. + * + *

Thread safety

+ * Not thread-safe. A single DML script executes instructions sequentially + * on one thread, so no synchronisation is needed. + * + * @see DPBuiltinCPInstruction + */ +public class RDPAccountant { + + // ----------------------------------------------------------------------- + // Rényi orders to track + // ----------------------------------------------------------------------- + + /** + * Discrete set of Rényi orders α. All must be > 1. + * Finer grids give tighter bounds; this set is a reasonable default + * that covers the range relevant for typical ML workloads. + */ + private static final double[] ORDERS = { + 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024 + }; + + // ----------------------------------------------------------------------- + // State + // ----------------------------------------------------------------------- + + /** Running accumulated Rényi divergence at each order. */ + private final double[] _rdpSum = new double[ORDERS.length]; + + /** User-specified total privacy budget (ε). */ + private final double _epsilonBudget; + + /** User-specified δ used for the RDP-to-(ε,δ) conversion. */ + private final double _delta; + + /** Number of releases recorded so far (for error messages). */ + private int _releaseCount = 0; + + // ----------------------------------------------------------------------- + // Constructor + // ----------------------------------------------------------------------- + + /** + * 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 RDP-to-(ε,δ) conversion (must be in (0,1)) + */ + public RDPAccountant(double epsilonBudget, double delta) { + if (!(epsilonBudget > 0)) + throw new DMLRuntimeException( + "RDPAccountant: epsilonBudget must be > 0, got " + epsilonBudget); + if (!(delta > 0 && delta < 1)) + throw new DMLRuntimeException( + "RDPAccountant: 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 RDPAccountant(double epsilonBudget) { + this(epsilonBudget, 1e-5); + } + + // ----------------------------------------------------------------------- + // 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. + * + *

The mechanism type (Laplace vs Gaussian) is inferred from the + * parameters: if {@code delta == 0} the release is treated as Laplace; + * otherwise it is treated as Gaussian. + * + * @param epsilon the ε parameter for this individual release (> 0) + * @param delta the δ parameter for this release (0 for Laplace) + * @param sensitivity the L2 sensitivity Δf of the released quantity (> 0) + * @throws DMLRuntimeException if the cumulative ε after this release + * would exceed the budget + */ + public void compose(double epsilon, double delta, double sensitivity) { + _releaseCount++; + + // Accumulate Rényi divergence at each order. + for (int i = 0; i < ORDERS.length; i++) { + double alpha = ORDERS[i]; + double rdp; + if (delta == 0.0) { + rdp = rdpLaplace(alpha, sensitivity, epsilon); + } else { + // Back-derive σ from the (ε, δ) calibration formula for the + // Gaussian mechanism, then compute the RDP contribution. + double sigma = gaussianSigma(sensitivity, epsilon, delta); + rdp = rdpGaussian(alpha, sensitivity, sigma); + } + _rdpSum[i] += rdp; + } + + // Convert accumulated RDP to (ε, δ) and check. + 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 under the + * accountant's δ, using the tightest available Rényi order. + */ + public double totalEpsilonSpent() { + double minEpsilon = Double.MAX_VALUE; + for (int i = 0; i < ORDERS.length; i++) { + double alpha = ORDERS[i]; + // Standard RDP-to-(ε,δ) conversion: + // ε(α) = R[α] + log(1 - 1/α) - log(δ·(α-1)) / α + // Reference: Mironov 2017, Proposition 3. + double eps = _rdpSum[i] + + Math.log(1.0 - 1.0 / alpha) + - Math.log(_delta * (alpha - 1.0)) / alpha; + if (eps < minEpsilon) + minEpsilon = eps; + } + return minEpsilon; + } + + /** Returns the remaining ε budget (may be negative if budget is exceeded). */ + public double remainingBudget() { + return _epsilonBudget - totalEpsilonSpent(); + } + + /** Returns the number of DP releases recorded so far. */ + public int releaseCount() { + return _releaseCount; + } + + // ----------------------------------------------------------------------- + // Mechanism-specific RDP contributions + // ----------------------------------------------------------------------- + + /** + * Rényi divergence of order α for the Laplace mechanism with scale + * b = sensitivity / epsilon. + * + *

For α > 1 and integer α, the closed form is: + *

+     *   D_α = (1/(α-1)) · ln( α/(2α-1)·exp((α-1)/b) + (α-1)/(2α-1)·exp(-α/b) )
+     * 
+ * + *

For non-integer α we use the same formula, which is the natural + * analytic continuation (see Mironov 2017, Proposition 3, example 1). + * We clamp the log argument to avoid NaN when inputs are degenerate. + */ + private static double rdpLaplace(double alpha, double sensitivity, double epsilon) { + double b = sensitivity / epsilon; // Laplace scale + double t1 = alpha / (2.0 * alpha - 1.0) * Math.exp((alpha - 1.0) / b); + double t2 = (alpha - 1.0) / (2.0 * alpha - 1.0) * Math.exp(-alpha / b); + double arg = t1 + t2; + if (arg <= 0) return 0.0; // degenerate: treat as zero cost + return Math.log(arg) / (alpha - 1.0); + } + + /** + * Rényi divergence of order α for the Gaussian mechanism with noise + * scale σ and L2 sensitivity Δf. + * + *

For α > 1: + *

+     *   D_α = α · Δf² / (2σ²)
+     * 
+ * + *

This is the standard result for the Gaussian mechanism (see + * Mironov 2017, Proposition 3, example 2). + */ + private static double rdpGaussian(double alpha, double sensitivity, double sigma) { + return alpha * (sensitivity * sensitivity) / (2.0 * sigma * sigma); + } + + /** + * Back-derives the Gaussian noise scale σ from the (ε, δ)-DP parameters + * using the standard calibration inequality: + *

+     *   σ = Δf · sqrt(2 · ln(1.25 / δ)) / ε
+     * 
+ * + *

This is the formula used by {@code DPBuiltinCPInstruction} to + * generate the actual noise, so the RDP contribution it records is + * exactly consistent with the noise 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/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..e9bc2c6ea9e --- /dev/null +++ b/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java @@ -0,0 +1,350 @@ +/* + * 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.functions.privacy.dp; + +import org.apache.sysds.runtime.DMLRuntimeException; +import org.apache.sysds.runtime.privacy.dp.RDPAccountant; +import org.junit.Test; + +import static org.junit.Assert.*; + +/** + * Tests for {@code DPBuiltinCPInstruction} and {@code RDPAccountant}. + * + *

The tests are grouped into three levels: + *

    + *
  1. Unit tests on RDPAccountant — verify composition, conversion, + * and budget enforcement in isolation, with no dependency on the full + * SystemDS runtime.
  2. + *
  3. 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).
  4. + *
  5. DML integration tests — run complete DML scripts and verify + * end-to-end correctness via the existing AutomatedTestBase machinery.
  6. + *
+ * + *

The DML integration tests require a built SystemDS jar and are separated + * into a companion class {@code DPBuiltinDMLTest} (shown at the bottom of + * this file as a skeleton). + */ +public class DPBuiltinCPInstructionTest { + + private static final double EPS = 1e-9; + + // ======================================================================= + // 1. RDPAccountant unit tests + // ======================================================================= + + @Test + public void testAccountantInitialisesAtZeroCost() { + RDPAccountant acc = new RDPAccountant(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. + RDPAccountant acc = new RDPAccountant(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() { + RDPAccountant acc = new RDPAccountant(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. + RDPAccountant acc = new RDPAccountant(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() { + RDPAccountant acc = new RDPAccountant(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; + + RDPAccountant gaussian = new RDPAccountant(100.0, delta); + RDPAccountant laplace = new RDPAccountant(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() { + RDPAccountant acc = new RDPAccountant(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 testSmallerSensitivityCheaper() { + // A release with sensitivity 0.1 should consume less budget than + // one with sensitivity 1.0 at the same (ε, δ). + RDPAccountant acc1 = new RDPAccountant(100.0, 1e-5); + RDPAccountant acc2 = new RDPAccountant(100.0, 1e-5); + acc1.compose(0.5, 1e-5, 0.1); // low sensitivity + acc2.compose(0.5, 1e-5, 1.0); // high sensitivity + + assertTrue("Lower sensitivity must cost less budget", + acc1.totalEpsilonSpent() < acc2.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. + // + // Note: these tests exercise the static fill* methods indirectly by + // calling the noise-generation logic via reflection or by making the + // methods package-private. The simplest approach for a student project + // is to make fillLaplaceNoise / fillGaussianNoise package-private and + // call them directly from the test (same package). + + @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); + } +} + + +// ========================================================================== +// 3. DML integration test skeleton +// ========================================================================== +// +// 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). +// +// The test below is a skeleton that compiles but needs the full SystemDS +// test infrastructure to run. + +/* +package org.apache.sysds.test.functions.privacy.dp; + +import org.apache.sysds.common.Types; +import org.apache.sysds.runtime.matrix.data.MatrixBlock; +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 String DML_LAPLACE = + "X = read($1);\n" + + "result = dp_laplace(colMeans(X), sensitivity=1.0, epsilon=$2);\n" + + "write(result, $3, format=\"binary\");\n"; + private static final String DML_GAUSSIAN = + "X = read($1);\n" + + "result = dp_gaussian(colMeans(X), sensitivity=1.0, epsilon=$2, delta=1e-5);\n" + + "write(result, $3, format=\"binary\");\n"; + + @Override + public void setUp() { + addTestConfiguration("DPLaplace", new TestConfiguration(TEST_CLASS, "DPLaplace")); + addTestConfiguration("DPGaussian", new TestConfiguration(TEST_CLASS, "DPGaussian")); + } + + @Test + public void testLaplaceOutputDiffersFromCleanMean() { + runDPTest("DPLaplace", DML_LAPLACE, "0.5"); + } + + @Test + public void testGaussianOutputDiffersFromCleanMean() { + runDPTest("DPGaussian", DML_GAUSSIAN, "0.5"); + } + + @Test + public void testHighEpsilonIsCloserToTruth() { + // Higher ε → less noise → result closer to the true mean. + double noisyLow = maxAbsDiff("DPGaussian", DML_GAUSSIAN, "0.1"); + double noisyHigh = maxAbsDiff("DPGaussian", DML_GAUSSIAN, "4.0"); + assertTrue("ε=4 should give less noise than ε=0.1", noisyHigh < noisyLow); + } + + private void runDPTest(String testName, String dml, String epsilonStr) { + getAndLoadTestConfiguration(testName); + int rows = 100, cols = 10; + double[][] data = TestUtils.generateTestMatrix(rows, cols, 0, 1, 1.0, 42); + writeInputMatrixWithMTD("X", data, false); + writeScriptFile(testName + ".dml", dml); + programArgs = new String[]{ input("X"), epsilonStr, output("result") }; + runTest(true, false, null, -1); + MatrixBlock result = readDMLMatrixFromHDFS("result"); + // The noisy result should be a (1 × cols) row vector. + assertEquals(1, result.getNumRows()); + assertEquals(cols, result.getNumColumns()); + // Must differ from the exact mean by a non-trivial amount. + // (A single-seed exact-equality check is fragile; use range check.) + double maxNoise = maxAbsValue(result); + assertTrue("Result should not be exactly zero", maxNoise > 0); + } + + private double maxAbsDiff(String testName, String dml, String epsilonStr) { + // Omitted for brevity: run the test, compute max |noisy - clean|. + return 0; // placeholder + } + + private static double maxAbsValue(MatrixBlock m) { + double max = 0; + for (int r = 0; r < m.getNumRows(); r++) + for (int c = 0; c < m.getNumColumns(); c++) + max = Math.max(max, Math.abs(m.get(r, c))); + return max; + } +} +*/ From 52b87c8ece7e330bec756f97a6d3fc539ed79f68 Mon Sep 17 00:00:00 2001 From: Maya Anderson Date: Fri, 26 Jun 2026 21:01:04 +0200 Subject: [PATCH 02/43] Add dp_gaussian and dp_laplace operations --- .../org/apache/sysds/common/Builtins.java | 2 ++ .../apache/sysds/common/InstructionType.java | 1 + .../java/org/apache/sysds/common/Opcodes.java | 4 +++ .../java/org/apache/sysds/common/Types.java | 2 +- .../sysds/hops/ParameterizedBuiltinOp.java | 15 ++++++++--- .../sysds/lops/ParameterizedBuiltin.java | 14 ++++++++++- .../parser/BuiltinFunctionExpression.java | 25 +++++++++++++++++++ .../apache/sysds/parser/DMLTranslator.java | 23 +++++++++++++++++ .../context/ExecutionContext.java | 9 +++++++ .../instructions/CPInstructionParser.java | 6 ++++- .../instructions/cp/CPInstruction.java | 3 ++- .../cp/DPBuiltinCPInstruction.java | 5 +--- .../cp/DPBuiltinCPInstructionTest.java | 14 +++++++---- 13 files changed, 106 insertions(+), 17 deletions(-) diff --git a/src/main/java/org/apache/sysds/common/Builtins.java b/src/main/java/org/apache/sysds/common/Builtins.java index f5719641df7..be6ee0f33db 100644 --- a/src/main/java/org/apache/sysds/common/Builtins.java +++ b/src/main/java/org/apache/sysds/common/Builtins.java @@ -116,6 +116,8 @@ public enum Builtins { DECISIONTREEPREDICT("decisionTreePredict", true), DECOMPRESS("decompress", false), DEDUP("dedup", true), + DP_LAPLACE("dp_laplace", false), + DP_GAUSSIAN("dp_gaussian", 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..250fc45a5c7 100644 --- a/src/main/java/org/apache/sysds/common/Types.java +++ b/src/main/java/org/apache/sysds/common/Types.java @@ -806,7 +806,7 @@ 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..7761521e415 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,11 @@ 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() ) + ret = new MatrixCharacteristics(dc.getRows(), dc.getCols(), -1, dc.getLength()); + } + return ret; } @Override @@ -758,7 +764,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..1425a794575 100644 --- a/src/main/java/org/apache/sysds/parser/BuiltinFunctionExpression.java +++ b/src/main/java/org/apache/sysds/parser/BuiltinFunctionExpression.java @@ -2005,6 +2005,31 @@ else if(this.getOpCode() == Builtins.MAX_POOL || this.getOpCode() == Builtins.AV } else raiseValidateError("Local instruction not allowed in dml script"); + case DP_LAPLACE: { + checkNumParameters(3); + checkMatrixParam(getFirstExpr()); + checkScalarParam(getSecondExpr()); + checkScalarParam(getThirdExpr()); + output.setDataType(DataType.MATRIX); + output.setValueType(ValueType.FP64); + output.setDimensions( + getFirstExpr().getOutput().getDim1(), + getFirstExpr().getOutput().getDim2()); + break; + } + case DP_GAUSSIAN: { + checkNumParameters(4); + checkMatrixParam(getFirstExpr()); + checkScalarParam(getSecondExpr()); + checkScalarParam(getThirdExpr()); + checkScalarParam(getFourthExpr()); + output.setDataType(DataType.MATRIX); + output.setValueType(ValueType.FP64); + output.setDimensions( + getFirstExpr().getOutput().getDim1(), + getFirstExpr().getOutput().getDim2()); + break; + } case COMPRESS: case DECOMPRESS: if(OptimizerUtils.ALLOW_SCRIPT_LEVEL_COMPRESS_COMMAND){ diff --git a/src/main/java/org/apache/sysds/parser/DMLTranslator.java b/src/main/java/org/apache/sysds/parser/DMLTranslator.java index a8e1667d049..7950868e0b5 100644 --- a/src/main/java/org/apache/sysds/parser/DMLTranslator.java +++ b/src/main/java/org/apache/sysds/parser/DMLTranslator.java @@ -2310,6 +2310,10 @@ private Hop processBuiltinFunctionExpression(BuiltinFunctionExpression source, D if (source.getThirdExpr() != null) { expr3 = processExpression(source.getThirdExpr(), null, hops); } + Hop expr4 = null; + if (source.getFourthExpr() != null) { + expr4 = processExpression(source.getFourthExpr(), null, hops); + } Hop currBuiltinOp = null; target = (target == null) ? createTarget(source) : target; @@ -2589,6 +2593,25 @@ 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: { + LinkedHashMap dpLaplaceParams = new LinkedHashMap<>(); + dpLaplaceParams.put("target", expr); + dpLaplaceParams.put("sensitivity", expr2); + dpLaplaceParams.put("epsilon", expr3); + currBuiltinOp = new ParameterizedBuiltinOp(target.getName(), DataType.MATRIX, + ValueType.FP64, ParamBuiltinOp.DP_LAPLACE, dpLaplaceParams); + break; + } + case DP_GAUSSIAN: { + LinkedHashMap dpGaussianParams = new LinkedHashMap<>(); + dpGaussianParams.put("target", expr); + dpGaussianParams.put("sensitivity", expr2); + dpGaussianParams.put("epsilon", expr3); + dpGaussianParams.put("delta", expr4); + currBuiltinOp = new ParameterizedBuiltinOp(target.getName(), DataType.MATRIX, + ValueType.FP64, ParamBuiltinOp.DP_GAUSSIAN, dpGaussianParams); + 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..baa6e74df7d 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 @@ -62,6 +62,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.RDPAccountant; import org.apache.sysds.utils.Statistics; import java.util.ArrayList; @@ -90,6 +91,8 @@ public class ExecutionContext { protected SEALClient _seal_client; + private RDPAccountant _rdpAccountant = null; + //parfor temporary functions (created by eval) protected Set _fnNames; @@ -144,6 +147,12 @@ public void setLineage(Lineage lineage) { _lineage = lineage; } + public RDPAccountant getRDPAccountant() { + if (_rdpAccountant == null) + _rdpAccountant = new RDPAccountant(1.0, 1e-5); + return _rdpAccountant; + } + 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 index f75ccb13cdb..746c8471ff8 100755 --- a/src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinCPInstruction.java +++ b/src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinCPInstruction.java @@ -102,10 +102,7 @@ private DPBuiltinCPInstruction( String opcode, String istr, LinkedHashMap params) { - // input1 = the aggregate matrix; input2/3 unused at this level - // (scalars come from _params, not CPOperand fields, so that they - // can be either literals or DML variable names transparently). - super(null, input, null, null, output, opcode, istr); + super(CPType.DPBuiltin, null, input, null, output, opcode, istr); _params = params; } 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 index e9bc2c6ea9e..6363b925b99 100755 --- a/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java +++ b/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java @@ -142,14 +142,18 @@ public void testRemainingBudgetDecreasesMonotonically() { @Test public void testSmallerSensitivityCheaper() { - // A release with sensitivity 0.1 should consume less budget than - // one with sensitivity 1.0 at the same (ε, δ). + // For the Gaussian mechanism, noise scales proportionally with sensitivity + // (sigma = sensitivity * C), so sensitivity² cancels in the RDP formula + // alpha * sensitivity² / (2 * sigma²). Budget consumed is epsilon-determined. + // For Laplace, higher sensitivity means a larger noise scale b = sensitivity/epsilon, + // which yields LOWER RDP divergence (more noise → better privacy). + // Test the Laplace case: higher sensitivity at same epsilon costs less budget. RDPAccountant acc1 = new RDPAccountant(100.0, 1e-5); RDPAccountant acc2 = new RDPAccountant(100.0, 1e-5); - acc1.compose(0.5, 1e-5, 0.1); // low sensitivity - acc2.compose(0.5, 1e-5, 1.0); // high sensitivity + acc1.compose(0.5, 0.0, 1.0); // high sensitivity, Laplace + acc2.compose(0.5, 0.0, 0.1); // low sensitivity, Laplace - assertTrue("Lower sensitivity must cost less budget", + assertTrue("Higher sensitivity costs less budget (Laplace: more noise for same epsilon)", acc1.totalEpsilonSpent() < acc2.totalEpsilonSpent()); } From 01b938a4a0cef83765d08f1d70ab749a49df6fa0 Mon Sep 17 00:00:00 2001 From: Maya Anderson Date: Thu, 2 Jul 2026 14:39:38 +0200 Subject: [PATCH 03/43] Fix test package name. --- src/main/java/org/apache/sysds/common/Types.java | 3 ++- .../sysds/test/component/cp/DPBuiltinCPInstructionTest.java | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/apache/sysds/common/Types.java b/src/main/java/org/apache/sysds/common/Types.java index 250fc45a5c7..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, DP_LAPLACE, DP_GAUSSIAN, 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/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java b/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java index 6363b925b99..68b40d4d170 100755 --- a/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java +++ b/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java @@ -17,7 +17,7 @@ * under the License. */ -package org.apache.sysds.test.functions.privacy.dp; +package org.apache.sysds.test.component.cp; import org.apache.sysds.runtime.DMLRuntimeException; import org.apache.sysds.runtime.privacy.dp.RDPAccountant; From 643ec8940d11739d1cc045190aa2d4c17ca3303a Mon Sep 17 00:00:00 2001 From: Maya Anderson Date: Mon, 6 Jul 2026 11:12:59 +0200 Subject: [PATCH 04/43] Fix Laplace accountant --- .../runtime/privacy/dp/RDPAccountant.java | 88 ++++++++++--------- .../cp/DPBuiltinCPInstructionTest.java | 18 ++-- 2 files changed, 53 insertions(+), 53 deletions(-) diff --git a/src/main/java/org/apache/sysds/runtime/privacy/dp/RDPAccountant.java b/src/main/java/org/apache/sysds/runtime/privacy/dp/RDPAccountant.java index 884012e4ee6..98d87e3a9e4 100755 --- a/src/main/java/org/apache/sysds/runtime/privacy/dp/RDPAccountant.java +++ b/src/main/java/org/apache/sysds/runtime/privacy/dp/RDPAccountant.java @@ -94,9 +94,19 @@ public class RDPAccountant { // State // ----------------------------------------------------------------------- - /** Running accumulated Rényi divergence at each order. */ + /** Running 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-to-(ε,δ) conversion path for Laplace, which introduces an + * unneeded δ and produces a looser bound. We accumulate Laplace cost here + * and add it directly in {@link #totalEpsilonSpent()}. + */ + private double _pureEpsilonSum = 0.0; + /** User-specified total privacy budget (ε). */ private final double _epsilonBudget; @@ -164,19 +174,20 @@ public RDPAccountant(double epsilonBudget) { public void compose(double epsilon, double delta, double sensitivity) { _releaseCount++; - // Accumulate Rényi divergence at each order. - for (int i = 0; i < ORDERS.length; i++) { - double alpha = ORDERS[i]; - double rdp; - if (delta == 0.0) { - rdp = rdpLaplace(alpha, sensitivity, epsilon); - } else { - // Back-derive σ from the (ε, δ) calibration formula for the - // Gaussian mechanism, then compute the RDP contribution. + if (delta == 0.0) { + // Laplace mechanism: pure ε-DP. Basic composition is exact and + // tighter than converting through RDP (which would introduce an + // unnecessary δ and often produce a looser ε bound). + _pureEpsilonSum += epsilon; + } else { + // Gaussian mechanism: accumulate Rényi divergence at each order. + // Back-derive σ from the (ε, δ) calibration formula, then add the + // RDP contribution. + for (int i = 0; i < ORDERS.length; i++) { + double alpha = ORDERS[i]; double sigma = gaussianSigma(sensitivity, epsilon, delta); - rdp = rdpGaussian(alpha, sensitivity, sigma); + _rdpSum[i] += rdpGaussian(alpha, sensitivity, sigma); } - _rdpSum[i] += rdp; } // Convert accumulated RDP to (ε, δ) and check. @@ -195,23 +206,36 @@ public void compose(double epsilon, double delta, double sensitivity) { // ----------------------------------------------------------------------- /** - * Returns the current total privacy cost as an ε value under the - * accountant's δ, using the tightest available Rényi order. + * Returns the current total privacy cost as an ε value. + * + *

The total cost combines two independent composition paths: + *

    + *
  • Laplace releases: pure ε-DP, accumulated via basic composition + * (exact and tighter than the RDP conversion path for Laplace).
  • + *
  • Gaussian releases: accumulated via Rényi DP, then converted to + * (ε, δ) using the tightest available order α.
  • + *
+ * + *

The combined guarantee is (ε_total, δ)-DP where ε_total is the sum + * of the two contributions (basic composition of a pure-DP mechanism with + * an approximate-DP mechanism is additive in ε). */ public double totalEpsilonSpent() { - double minEpsilon = Double.MAX_VALUE; + // Gaussian contribution via RDP → (ε, δ) conversion (Mironov 2017, Prop. 3). + double gaussianEps = Double.MAX_VALUE; for (int i = 0; i < ORDERS.length; i++) { double alpha = ORDERS[i]; - // Standard RDP-to-(ε,δ) conversion: - // ε(α) = R[α] + log(1 - 1/α) - log(δ·(α-1)) / α - // Reference: Mironov 2017, Proposition 3. double eps = _rdpSum[i] + Math.log(1.0 - 1.0 / alpha) - Math.log(_delta * (alpha - 1.0)) / alpha; - if (eps < minEpsilon) - minEpsilon = eps; + if (eps < gaussianEps) + gaussianEps = eps; } - return minEpsilon; + // If no Gaussian releases have occurred, the RDP conversion yields a + // large positive value (log-delta term dominates). Clamp to zero so + // it doesn't inflate the total when only Laplace releases are present. + if (gaussianEps < 0) gaussianEps = 0.0; + return _pureEpsilonSum + gaussianEps; } /** Returns the remaining ε budget (may be negative if budget is exceeded). */ @@ -228,28 +252,6 @@ public int releaseCount() { // Mechanism-specific RDP contributions // ----------------------------------------------------------------------- - /** - * Rényi divergence of order α for the Laplace mechanism with scale - * b = sensitivity / epsilon. - * - *

For α > 1 and integer α, the closed form is: - *

-     *   D_α = (1/(α-1)) · ln( α/(2α-1)·exp((α-1)/b) + (α-1)/(2α-1)·exp(-α/b) )
-     * 
- * - *

For non-integer α we use the same formula, which is the natural - * analytic continuation (see Mironov 2017, Proposition 3, example 1). - * We clamp the log argument to avoid NaN when inputs are degenerate. - */ - private static double rdpLaplace(double alpha, double sensitivity, double epsilon) { - double b = sensitivity / epsilon; // Laplace scale - double t1 = alpha / (2.0 * alpha - 1.0) * Math.exp((alpha - 1.0) / b); - double t2 = (alpha - 1.0) / (2.0 * alpha - 1.0) * Math.exp(-alpha / b); - double arg = t1 + t2; - if (arg <= 0) return 0.0; // degenerate: treat as zero cost - return Math.log(arg) / (alpha - 1.0); - } - /** * Rényi divergence of order α for the Gaussian mechanism with noise * scale σ and L2 sensitivity Δf. 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 index 68b40d4d170..fdeac21ff1b 100755 --- a/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java +++ b/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java @@ -141,19 +141,17 @@ public void testRemainingBudgetDecreasesMonotonically() { } @Test - public void testSmallerSensitivityCheaper() { - // For the Gaussian mechanism, noise scales proportionally with sensitivity - // (sigma = sensitivity * C), so sensitivity² cancels in the RDP formula - // alpha * sensitivity² / (2 * sigma²). Budget consumed is epsilon-determined. - // For Laplace, higher sensitivity means a larger noise scale b = sensitivity/epsilon, - // which yields LOWER RDP divergence (more noise → better privacy). - // Test the Laplace case: higher sensitivity at same epsilon costs less budget. + 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. RDPAccountant acc1 = new RDPAccountant(100.0, 1e-5); RDPAccountant acc2 = new RDPAccountant(100.0, 1e-5); - acc1.compose(0.5, 0.0, 1.0); // high sensitivity, Laplace - acc2.compose(0.5, 0.0, 0.1); // low sensitivity, Laplace + 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 sensitivity costs less budget (Laplace: more noise for same epsilon)", + assertTrue("Higher epsilon costs more budget (Laplace basic composition)", acc1.totalEpsilonSpent() < acc2.totalEpsilonSpent()); } From 6eecbeae232decf5fe1062227acebfb0b2815794 Mon Sep 17 00:00:00 2001 From: Maya Anderson Date: Mon, 6 Jul 2026 17:22:33 +0200 Subject: [PATCH 05/43] Add unit tests --- .../cp/DPBuiltinCPInstructionTest.java | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) 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 index fdeac21ff1b..c04a901e650 100755 --- a/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java +++ b/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java @@ -155,6 +155,122 @@ public void testHigherEpsilonCostMoreForLaplace() { acc1.totalEpsilonSpent() < acc2.totalEpsilonSpent()); } + // --- Item 1: constructor error paths ------------------------------------ + + @Test(expected = DMLRuntimeException.class) + public void testConstructorRejectsZeroEpsilonBudget() { + new RDPAccountant(0.0, 1e-5); + } + + @Test(expected = DMLRuntimeException.class) + public void testConstructorRejectsNegativeEpsilonBudget() { + new RDPAccountant(-0.5, 1e-5); + } + + @Test(expected = DMLRuntimeException.class) + public void testConstructorRejectsDeltaZero() { + new RDPAccountant(1.0, 0.0); + } + + @Test(expected = DMLRuntimeException.class) + public void testConstructorRejectsDeltaOne() { + new RDPAccountant(1.0, 1.0); + } + + // --- Item 2: 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. + RDPAccountant oneArg = new RDPAccountant(10.0); + RDPAccountant twoArg = new RDPAccountant(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); + } + + // --- Item 3: budget exhaustion via Gaussian releases ------------------- + + @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. + RDPAccountant acc = new RDPAccountant(0.1, 1e-5); + for (int i = 0; i < 20; i++) { + acc.compose(0.3, 1e-5, 1.0); + } + } + + // --- Item 4: mixed Laplace + Gaussian composition ---------------------- + + @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(). + RDPAccountant mixed = new RDPAccountant(100.0, 1e-5); + RDPAccountant lapOnly = new RDPAccountant(100.0, 1e-5); + RDPAccountant gauOnly = new RDPAccountant(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()); + } + + // --- Item 6: release count across multiple mixed releases -------------- + + @Test + public void testReleaseCountTracksAllReleases() { + RDPAccountant acc = new RDPAccountant(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()); + } + + // --- Item 8: 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(). + RDPAccountant acc1 = new RDPAccountant(100.0, 1e-5); + RDPAccountant acc2 = new RDPAccountant(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 ε. + RDPAccountant lowEps = new RDPAccountant(100.0, 1e-5); + RDPAccountant highEps = new RDPAccountant(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) // ======================================================================= From 7d3e25ed6cb2ebac104266c0c2ca804b24b03f1c Mon Sep 17 00:00:00 2001 From: Maya Anderson Date: Mon, 6 Jul 2026 17:27:44 +0200 Subject: [PATCH 06/43] Rename RDPAccountant to DPBudgetAccountant --- .../context/ExecutionContext.java | 12 +- .../cp/DPBuiltinCPInstruction.java | 14 +- .../privacy/dp/DPBudgetAccountant.java | 271 ++++++++++++++++++ .../runtime/privacy/dp/RDPAccountant.java | 264 +---------------- .../cp/DPBuiltinCPInstructionTest.java | 58 ++-- 5 files changed, 320 insertions(+), 299 deletions(-) create mode 100644 src/main/java/org/apache/sysds/runtime/privacy/dp/DPBudgetAccountant.java 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 baa6e74df7d..eaf50da88c7 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 @@ -62,7 +62,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.RDPAccountant; +import org.apache.sysds.runtime.privacy.dp.DPBudgetAccountant; import org.apache.sysds.utils.Statistics; import java.util.ArrayList; @@ -91,7 +91,7 @@ public class ExecutionContext { protected SEALClient _seal_client; - private RDPAccountant _rdpAccountant = null; + private DPBudgetAccountant _dpBudgetAccountant = null; //parfor temporary functions (created by eval) protected Set _fnNames; @@ -147,10 +147,10 @@ public void setLineage(Lineage lineage) { _lineage = lineage; } - public RDPAccountant getRDPAccountant() { - if (_rdpAccountant == null) - _rdpAccountant = new RDPAccountant(1.0, 1e-5); - return _rdpAccountant; + public DPBudgetAccountant getDPBudgetAccountant() { + if (_dpBudgetAccountant == null) + _dpBudgetAccountant = new DPBudgetAccountant(1.0, 1e-5); + return _dpBudgetAccountant; } public boolean isAutoCreateVars() { 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 index 746c8471ff8..be02be1cae7 100755 --- a/src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinCPInstruction.java +++ b/src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinCPInstruction.java @@ -25,7 +25,7 @@ import org.apache.sysds.runtime.instructions.InstructionUtils; import org.apache.sysds.runtime.matrix.data.MatrixBlock; import org.apache.sysds.runtime.matrix.operators.BinaryOperator; -import org.apache.sysds.runtime.privacy.dp.RDPAccountant; +import org.apache.sysds.runtime.privacy.dp.DPBudgetAccountant; import java.util.LinkedHashMap; import java.util.concurrent.ThreadLocalRandom; @@ -42,7 +42,7 @@ * *

The instruction receives a materialised matrix (the aggregate result), * injects calibrated noise element-wise, records the release with the - * session-scoped {@link RDPAccountant}, and returns the noisy matrix. + * session-scoped {@link DPBudgetAccountant}, and returns the noisy matrix. * *

Noise is generated in Java and added via a {@code MatrixBlock} binary * operation so that the output allocation path is identical to every other @@ -64,8 +64,8 @@ * add opcode-to-type mappings and a parse branch that returns a * {@code DPBuiltinCPInstruction} *

  • {@code org.apache.sysds.runtime.controlprogram.context.ExecutionContext} - * – add {@code getRDPAccountant()} returning a session-scoped - * {@link RDPAccountant} (lazy-initialised field)
  • + * – add {@code getDPBudgetAccountant()} returning a session-scoped + * {@link DPBudgetAccountant} (lazy-initialised field) * */ public class DPBuiltinCPInstruction extends ComputationCPInstruction { @@ -169,7 +169,7 @@ public static DPBuiltinCPInstruction parseInstruction(String str) { *
  • Generate a noise {@link MatrixBlock} of the same shape.
  • *
  • Add noise element-wise using the existing binary-operator path.
  • *
  • Record the release with the session-scoped - * {@link RDPAccountant}; throw if budget is exhausted.
  • + * {@link DPBudgetAccountant}; throw if budget is exhausted. *
  • Write the noisy block back to the variable table and release * the input pin.
  • * @@ -200,9 +200,9 @@ public void processInstruction(ExecutionContext ec) { inBlock.binaryOperations(plusOp, noiseBlock, outBlock); // ── 5. Record release and enforce budget ──────────────────────────── - // getRDPAccountant() returns a lazy-initialised RDPAccountant that is + // getDPBudgetAccountant() returns a lazy-initialised DPBudgetAccountant that is // owned by this ExecutionContext (added in a companion EC patch). - RDPAccountant accountant = ec.getRDPAccountant(); + DPBudgetAccountant accountant = ec.getDPBudgetAccountant(); accountant.compose(epsilon, delta, sensitivity); // throws on exhaustion // ── 6. Write output and release input pin ─────────────────────────── 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..ce5afcb7850 --- /dev/null +++ b/src/main/java/org/apache/sysds/runtime/privacy/dp/DPBudgetAccountant.java @@ -0,0 +1,271 @@ +/* + * 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; + +/** + * Session-scoped differential privacy budget accountant. + * + *

    Purpose

    + * 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. + * + *

    Composition strategy

    + * 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 δ).
    • + *
    • 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)) / α
    + * 
    + * + *

    Lifecycle

    + * 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. + * + *

    Thread safety

    + * 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 + // ----------------------------------------------------------------------- + + /** + * 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); + } + + // ----------------------------------------------------------------------- + // 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 L2 sensitivity Δf of the released quantity (must be > 0) + * @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() { + 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 · ln(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/java/org/apache/sysds/runtime/privacy/dp/RDPAccountant.java b/src/main/java/org/apache/sysds/runtime/privacy/dp/RDPAccountant.java index 98d87e3a9e4..0cd3785db7b 100755 --- a/src/main/java/org/apache/sysds/runtime/privacy/dp/RDPAccountant.java +++ b/src/main/java/org/apache/sysds/runtime/privacy/dp/RDPAccountant.java @@ -19,267 +19,17 @@ package org.apache.sysds.runtime.privacy.dp; -import org.apache.sysds.runtime.DMLRuntimeException; - /** - * Session-scoped Rényi Differential Privacy (RDP) budget accountant. - * - *

    Purpose

    - * 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. - * - *

    Why Rényi DP?

    - * Basic composition adds epsilons linearly, giving very loose bounds with - * many releases. Rényi DP divergences compose additively at the - * same order α. Converting the running Rényi sum to (ε, δ) via the standard - * conversion formula yields substantially tighter bounds — particularly for - * Gaussian mechanisms, which are common in federated learning. - * - *

    Orders tracked

    - * We track a discrete set of Rényi orders α ∈ {2, 4, 8, 16, 32, 64, 128, - * 256, 512, 1024}. At query time we take the minimum converted ε across all - * orders, which is the tightest available bound. - * - *

    Composition rules

    - * For the Gaussian mechanism with noise scale σ and sensitivity Δf, the - * Rényi divergence of order α between outputs on neighbouring datasets is: - *
    - *   D_α = α · Δf² / (2σ²)
    - * 
    - * where σ is back-derived from the caller's (ε, δ) parameters via the - * standard calibration formula. See {@link #rdpGaussian} for details. - * - * For the Laplace mechanism with scale b = Δf/ε, the Rényi divergence at - * order α is: - *
    - *   D_α = (1/(α-1)) · ln( α/(2α-1) · exp((α-1)/b) + (α-1)/(2α-1) · exp(-α/b) )
    - *       (for α > 1; the limit as α → 1 is 1/b, i.e. the KL divergence)
    - * 
    - * - *

    Conversion: Rényi DP → (ε, δ)-DP

    - * Given accumulated Rényi divergence R[α] at order α and a target δ: - *
    - *   ε(α) = R[α] + log(1 - 1/α) - log(δ · (α - 1)) / α
    - * 
    - * The reported total cost is min_α ε(α). - * - *

    Lifecycle

    - * 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. - * - *

    Thread safety

    - * Not thread-safe. A single DML script executes instructions sequentially - * on one thread, so no synchronisation is needed. - * - * @see DPBuiltinCPInstruction + * @deprecated Use {@link DPBudgetAccountant} instead. */ -public class RDPAccountant { - - // ----------------------------------------------------------------------- - // Rényi orders to track - // ----------------------------------------------------------------------- - - /** - * Discrete set of Rényi orders α. All must be > 1. - * Finer grids give tighter bounds; this set is a reasonable default - * that covers the range relevant for typical ML workloads. - */ - private static final double[] ORDERS = { - 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024 - }; - - // ----------------------------------------------------------------------- - // State - // ----------------------------------------------------------------------- - - /** Running 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-to-(ε,δ) conversion path for Laplace, which introduces an - * unneeded δ and produces a looser bound. We accumulate Laplace cost here - * and add it directly in {@link #totalEpsilonSpent()}. - */ - private double _pureEpsilonSum = 0.0; - - /** User-specified total privacy budget (ε). */ - private final double _epsilonBudget; - - /** User-specified δ used for the RDP-to-(ε,δ) conversion. */ - private final double _delta; - - /** Number of releases recorded so far (for error messages). */ - private int _releaseCount = 0; - - // ----------------------------------------------------------------------- - // Constructor - // ----------------------------------------------------------------------- - - /** - * 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 RDP-to-(ε,δ) conversion (must be in (0,1)) - */ +@Deprecated +public class RDPAccountant extends DPBudgetAccountant { + @Deprecated public RDPAccountant(double epsilonBudget, double delta) { - if (!(epsilonBudget > 0)) - throw new DMLRuntimeException( - "RDPAccountant: epsilonBudget must be > 0, got " + epsilonBudget); - if (!(delta > 0 && delta < 1)) - throw new DMLRuntimeException( - "RDPAccountant: delta must be in (0,1), got " + delta); - _epsilonBudget = epsilonBudget; - _delta = delta; + super(epsilonBudget, delta); } - - /** - * Convenience constructor using a liberal default δ = 1e-5. - * Suitable when the calling script does not specify δ explicitly. - */ + @Deprecated public RDPAccountant(double epsilonBudget) { - this(epsilonBudget, 1e-5); - } - - // ----------------------------------------------------------------------- - // 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. - * - *

    The mechanism type (Laplace vs Gaussian) is inferred from the - * parameters: if {@code delta == 0} the release is treated as Laplace; - * otherwise it is treated as Gaussian. - * - * @param epsilon the ε parameter for this individual release (> 0) - * @param delta the δ parameter for this release (0 for Laplace) - * @param sensitivity the L2 sensitivity Δf of the released quantity (> 0) - * @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 mechanism: pure ε-DP. Basic composition is exact and - // tighter than converting through RDP (which would introduce an - // unnecessary δ and often produce a looser ε bound). - _pureEpsilonSum += epsilon; - } else { - // Gaussian mechanism: accumulate Rényi divergence at each order. - // Back-derive σ from the (ε, δ) calibration formula, then add the - // RDP contribution. - for (int i = 0; i < ORDERS.length; i++) { - double alpha = ORDERS[i]; - double sigma = gaussianSigma(sensitivity, epsilon, delta); - _rdpSum[i] += rdpGaussian(alpha, sensitivity, sigma); - } - } - - // Convert accumulated RDP to (ε, δ) and check. - 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. - * - *

    The total cost combines two independent composition paths: - *

      - *
    • Laplace releases: pure ε-DP, accumulated via basic composition - * (exact and tighter than the RDP conversion path for Laplace).
    • - *
    • Gaussian releases: accumulated via Rényi DP, then converted to - * (ε, δ) using the tightest available order α.
    • - *
    - * - *

    The combined guarantee is (ε_total, δ)-DP where ε_total is the sum - * of the two contributions (basic composition of a pure-DP mechanism with - * an approximate-DP mechanism is additive in ε). - */ - public double totalEpsilonSpent() { - // Gaussian contribution via RDP → (ε, δ) conversion (Mironov 2017, Prop. 3). - 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; - } - // If no Gaussian releases have occurred, the RDP conversion yields a - // large positive value (log-delta term dominates). Clamp to zero so - // it doesn't inflate the total when only Laplace releases are present. - if (gaussianEps < 0) gaussianEps = 0.0; - return _pureEpsilonSum + gaussianEps; - } - - /** Returns the remaining ε budget (may be negative if budget is exceeded). */ - public double remainingBudget() { - return _epsilonBudget - totalEpsilonSpent(); - } - - /** Returns the number of DP releases recorded so far. */ - public int releaseCount() { - return _releaseCount; - } - - // ----------------------------------------------------------------------- - // Mechanism-specific RDP contributions - // ----------------------------------------------------------------------- - - /** - * Rényi divergence of order α for the Gaussian mechanism with noise - * scale σ and L2 sensitivity Δf. - * - *

    For α > 1: - *

    -     *   D_α = α · Δf² / (2σ²)
    -     * 
    - * - *

    This is the standard result for the Gaussian mechanism (see - * Mironov 2017, Proposition 3, example 2). - */ - private static double rdpGaussian(double alpha, double sensitivity, double sigma) { - return alpha * (sensitivity * sensitivity) / (2.0 * sigma * sigma); - } - - /** - * Back-derives the Gaussian noise scale σ from the (ε, δ)-DP parameters - * using the standard calibration inequality: - *

    -     *   σ = Δf · sqrt(2 · ln(1.25 / δ)) / ε
    -     * 
    - * - *

    This is the formula used by {@code DPBuiltinCPInstruction} to - * generate the actual noise, so the RDP contribution it records is - * exactly consistent with the noise injected. - */ - private static double gaussianSigma(double sensitivity, double epsilon, double delta) { - return sensitivity * Math.sqrt(2.0 * Math.log(1.25 / delta)) / epsilon; + super(epsilonBudget); } } 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 index c04a901e650..ac7d4f9bd4f 100755 --- a/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java +++ b/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java @@ -20,17 +20,17 @@ package org.apache.sysds.test.component.cp; import org.apache.sysds.runtime.DMLRuntimeException; -import org.apache.sysds.runtime.privacy.dp.RDPAccountant; +import org.apache.sysds.runtime.privacy.dp.DPBudgetAccountant; import org.junit.Test; import static org.junit.Assert.*; /** - * Tests for {@code DPBuiltinCPInstruction} and {@code RDPAccountant}. + * Tests for {@code DPBuiltinCPInstruction} and {@code DPBudgetAccountant}. * *

    The tests are grouped into three levels: *

      - *
    1. Unit tests on RDPAccountant — verify composition, conversion, + *
    2. Unit tests on DPBudgetAccountant — verify composition, conversion, * and budget enforcement in isolation, with no dependency on the full * SystemDS runtime.
    3. *
    4. Noise distribution tests — verify that the noise blocks @@ -49,12 +49,12 @@ public class DPBuiltinCPInstructionTest { private static final double EPS = 1e-9; // ======================================================================= - // 1. RDPAccountant unit tests + // 1. DPBudgetAccountant unit tests // ======================================================================= @Test public void testAccountantInitialisesAtZeroCost() { - RDPAccountant acc = new RDPAccountant(1.0, 1e-5); + 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. @@ -66,7 +66,7 @@ public void testAccountantInitialisesAtZeroCost() { @Test public void testSingleLaplaceReleaseDoesNotExceedBudget() { // epsilon=0.5, budget=1.0: one release should consume < budget. - RDPAccountant acc = new RDPAccountant(1.0, 1e-5); + 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", @@ -75,7 +75,7 @@ public void testSingleLaplaceReleaseDoesNotExceedBudget() { @Test public void testSingleGaussianReleaseDoesNotExceedBudget() { - RDPAccountant acc = new RDPAccountant(1.0, 1e-5); + 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", @@ -86,7 +86,7 @@ public void testSingleGaussianReleaseDoesNotExceedBudget() { 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. - RDPAccountant acc = new RDPAccountant(0.1, 1e-5); + 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 } @@ -94,7 +94,7 @@ public void testBudgetExhaustionThrows() { @Test public void testCompositionIsMonotonicallyIncreasing() { - RDPAccountant acc = new RDPAccountant(100.0, 1e-5); // large budget + 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); @@ -114,8 +114,8 @@ public void testGaussianTighterThanLaplaceForSameEpsilon() { double eps = 0.5; double delta = 1e-5; - RDPAccountant gaussian = new RDPAccountant(100.0, delta); - RDPAccountant laplace = new RDPAccountant(100.0, delta); + 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); @@ -130,7 +130,7 @@ public void testGaussianTighterThanLaplaceForSameEpsilon() { @Test public void testRemainingBudgetDecreasesMonotonically() { - RDPAccountant acc = new RDPAccountant(2.0, 1e-5); + 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); @@ -146,8 +146,8 @@ public void testHigherEpsilonCostMoreForLaplace() { // 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. - RDPAccountant acc1 = new RDPAccountant(100.0, 1e-5); - RDPAccountant acc2 = new RDPAccountant(100.0, 1e-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 @@ -159,22 +159,22 @@ public void testHigherEpsilonCostMoreForLaplace() { @Test(expected = DMLRuntimeException.class) public void testConstructorRejectsZeroEpsilonBudget() { - new RDPAccountant(0.0, 1e-5); + new DPBudgetAccountant(0.0, 1e-5); } @Test(expected = DMLRuntimeException.class) public void testConstructorRejectsNegativeEpsilonBudget() { - new RDPAccountant(-0.5, 1e-5); + new DPBudgetAccountant(-0.5, 1e-5); } @Test(expected = DMLRuntimeException.class) public void testConstructorRejectsDeltaZero() { - new RDPAccountant(1.0, 0.0); + new DPBudgetAccountant(1.0, 0.0); } @Test(expected = DMLRuntimeException.class) public void testConstructorRejectsDeltaOne() { - new RDPAccountant(1.0, 1.0); + new DPBudgetAccountant(1.0, 1.0); } // --- Item 2: single-argument convenience constructor ------------------- @@ -184,8 +184,8 @@ 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. - RDPAccountant oneArg = new RDPAccountant(10.0); - RDPAccountant twoArg = new RDPAccountant(10.0, 1e-5); + 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", @@ -198,7 +198,7 @@ public void testConvenienceConstructorDefaultsDeltaTo1e5() { 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. - RDPAccountant acc = new RDPAccountant(0.1, 1e-5); + DPBudgetAccountant acc = new DPBudgetAccountant(0.1, 1e-5); for (int i = 0; i < 20; i++) { acc.compose(0.3, 1e-5, 1.0); } @@ -211,9 +211,9 @@ 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(). - RDPAccountant mixed = new RDPAccountant(100.0, 1e-5); - RDPAccountant lapOnly = new RDPAccountant(100.0, 1e-5); - RDPAccountant gauOnly = new RDPAccountant(100.0, 1e-5); + 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 @@ -231,7 +231,7 @@ public void testMixedCompositionExceedsEitherAlone() { @Test public void testReleaseCountTracksAllReleases() { - RDPAccountant acc = new RDPAccountant(100.0, 1e-5); + 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()); @@ -251,8 +251,8 @@ public void testGaussianSensitivityCancelsInRDP() { // D_α = α·Δf²/(2σ²) = α·ε²/(4·ln(1.25/δ)). // Sensitivity cancels. Two accountants with the same (ε,δ) but // different sensitivity must report identical totalEpsilonSpent(). - RDPAccountant acc1 = new RDPAccountant(100.0, 1e-5); - RDPAccountant acc2 = new RDPAccountant(100.0, 1e-5); + 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", @@ -263,8 +263,8 @@ public void testGaussianSensitivityCancelsInRDP() { 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 ε. - RDPAccountant lowEps = new RDPAccountant(100.0, 1e-5); - RDPAccountant highEps = new RDPAccountant(100.0, 1e-5); + 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", From 77db1925630599a8dfa96ed62a82bb2516eda8be Mon Sep 17 00:00:00 2001 From: Maya Anderson Date: Mon, 6 Jul 2026 17:55:33 +0200 Subject: [PATCH 07/43] Delete RDPAccountant --- .../runtime/privacy/dp/RDPAccountant.java | 35 ------------------- 1 file changed, 35 deletions(-) delete mode 100755 src/main/java/org/apache/sysds/runtime/privacy/dp/RDPAccountant.java diff --git a/src/main/java/org/apache/sysds/runtime/privacy/dp/RDPAccountant.java b/src/main/java/org/apache/sysds/runtime/privacy/dp/RDPAccountant.java deleted file mode 100755 index 0cd3785db7b..00000000000 --- a/src/main/java/org/apache/sysds/runtime/privacy/dp/RDPAccountant.java +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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; - -/** - * @deprecated Use {@link DPBudgetAccountant} instead. - */ -@Deprecated -public class RDPAccountant extends DPBudgetAccountant { - @Deprecated - public RDPAccountant(double epsilonBudget, double delta) { - super(epsilonBudget, delta); - } - @Deprecated - public RDPAccountant(double epsilonBudget) { - super(epsilonBudget); - } -} From af7b13ab185ca33ca9c5f2a927210657ed2fbc5c Mon Sep 17 00:00:00 2001 From: Maya Anderson Date: Mon, 6 Jul 2026 23:34:11 +0200 Subject: [PATCH 08/43] Fix parseInstruction() comment --- .../sysds/runtime/instructions/cp/DPBuiltinCPInstruction.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index be02be1cae7..a474c075a96 100755 --- a/src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinCPInstruction.java +++ b/src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinCPInstruction.java @@ -114,7 +114,7 @@ private DPBuiltinCPInstruction( * Reconstructs a {@code DPBuiltinCPInstruction} from its serialised * instruction string produced by the LOP layer. * - *

      Expected format (INSTRUCTION_DELIM = '\u00b0'): + *

      Expected format (OPERAND_DELIM = '\u00b0'): *

            *   dp_gaussian°target=mVar1·MATRIX·FP64°sensitivity=1.0·SCALAR·FP64·true
            *              °epsilon=0.5·SCALAR·FP64·true°delta=1e-5·SCALAR·FP64·true
      
      From 9dcbb08c5b5ab64f1532490eba664918763b255d Mon Sep 17 00:00:00 2001
      From: Maya Anderson 
      Date: Tue, 7 Jul 2026 00:18:37 +0200
      Subject: [PATCH 09/43] Integration tests
      
      ---
       .../cp/DPBuiltinCPInstructionTest.java        |  99 +------------
       .../privacy/dp/DPBuiltinDMLTest.java          | 134 ++++++++++++++++++
       2 files changed, 135 insertions(+), 98 deletions(-)
       create mode 100644 src/test/java/org/apache/sysds/test/functions/privacy/dp/DPBuiltinDMLTest.java
      
      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
      index ac7d4f9bd4f..14c11567cef 100755
      --- a/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java
      +++ b/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java
      @@ -41,8 +41,7 @@
        * 
    * *

    The DML integration tests require a built SystemDS jar and are separated - * into a companion class {@code DPBuiltinDMLTest} (shown at the bottom of - * this file as a skeleton). + * into a companion class {@link org.apache.sysds.test.functions.privacy.dp.DPBuiltinDMLTest}. */ public class DPBuiltinCPInstructionTest { @@ -370,99 +369,3 @@ private static double variance(double[] xs) { return s / (xs.length - 1); } } - - -// ========================================================================== -// 3. DML integration test skeleton -// ========================================================================== -// -// 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). -// -// The test below is a skeleton that compiles but needs the full SystemDS -// test infrastructure to run. - -/* -package org.apache.sysds.test.functions.privacy.dp; - -import org.apache.sysds.common.Types; -import org.apache.sysds.runtime.matrix.data.MatrixBlock; -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 String DML_LAPLACE = - "X = read($1);\n" - + "result = dp_laplace(colMeans(X), sensitivity=1.0, epsilon=$2);\n" - + "write(result, $3, format=\"binary\");\n"; - private static final String DML_GAUSSIAN = - "X = read($1);\n" - + "result = dp_gaussian(colMeans(X), sensitivity=1.0, epsilon=$2, delta=1e-5);\n" - + "write(result, $3, format=\"binary\");\n"; - - @Override - public void setUp() { - addTestConfiguration("DPLaplace", new TestConfiguration(TEST_CLASS, "DPLaplace")); - addTestConfiguration("DPGaussian", new TestConfiguration(TEST_CLASS, "DPGaussian")); - } - - @Test - public void testLaplaceOutputDiffersFromCleanMean() { - runDPTest("DPLaplace", DML_LAPLACE, "0.5"); - } - - @Test - public void testGaussianOutputDiffersFromCleanMean() { - runDPTest("DPGaussian", DML_GAUSSIAN, "0.5"); - } - - @Test - public void testHighEpsilonIsCloserToTruth() { - // Higher ε → less noise → result closer to the true mean. - double noisyLow = maxAbsDiff("DPGaussian", DML_GAUSSIAN, "0.1"); - double noisyHigh = maxAbsDiff("DPGaussian", DML_GAUSSIAN, "4.0"); - assertTrue("ε=4 should give less noise than ε=0.1", noisyHigh < noisyLow); - } - - private void runDPTest(String testName, String dml, String epsilonStr) { - getAndLoadTestConfiguration(testName); - int rows = 100, cols = 10; - double[][] data = TestUtils.generateTestMatrix(rows, cols, 0, 1, 1.0, 42); - writeInputMatrixWithMTD("X", data, false); - writeScriptFile(testName + ".dml", dml); - programArgs = new String[]{ input("X"), epsilonStr, output("result") }; - runTest(true, false, null, -1); - MatrixBlock result = readDMLMatrixFromHDFS("result"); - // The noisy result should be a (1 × cols) row vector. - assertEquals(1, result.getNumRows()); - assertEquals(cols, result.getNumColumns()); - // Must differ from the exact mean by a non-trivial amount. - // (A single-seed exact-equality check is fragile; use range check.) - double maxNoise = maxAbsValue(result); - assertTrue("Result should not be exactly zero", maxNoise > 0); - } - - private double maxAbsDiff(String testName, String dml, String epsilonStr) { - // Omitted for brevity: run the test, compute max |noisy - clean|. - return 0; // placeholder - } - - private static double maxAbsValue(MatrixBlock m) { - double max = 0; - for (int r = 0; r < m.getNumRows(); r++) - for (int c = 0; c < m.getNumColumns(); c++) - max = Math.max(max, Math.abs(m.get(r, c))); - return max; - } -} -*/ 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..df799d59c63 --- /dev/null +++ b/src/test/java/org/apache/sysds/test/functions/privacy/dp/DPBuiltinDMLTest.java @@ -0,0 +1,134 @@ +// ========================================================================== +// 3. DML integration test skeleton +// ========================================================================== +// +// 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.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.HashMap; + +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 = + "X = read($1);\n" + + "result = dp_laplace(colMeans(X), sensitivity=1.0, epsilon=$2);\n" + + "write(result, $3, format=\"text\");\n"; + private static final String DML_GAUSSIAN = + "X = read($1);\n" + + "result = dp_gaussian(colMeans(X), sensitivity=1.0, epsilon=$2, delta=1e-5);\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")); + } + + @Test + public void testLaplaceOutputDiffersFromCleanMean() { + runDPTest("DPLaplace", DML_LAPLACE, "0.5"); + } + + @Test + public void testGaussianOutputDiffersFromCleanMean() { + runDPTest("DPGaussian", DML_GAUSSIAN, "0.5"); + } + + @Test + public void testHighEpsilonIsCloserToTruth() { + // 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 = maxAbsDiff("DPGaussian", DML_GAUSSIAN, "0.1"); + double noisyHigh = maxAbsDiff("DPGaussian", DML_GAUSSIAN, "0.5"); + assertTrue("ε=0.5 should give less noise than ε=0.1", noisyHigh < noisyLow); + } + + private void runDPTest(String testName, String dml, String epsilonStr) { + double[][] data = TestUtils.generateTestMatrix(ROWS, COLS, 0, 1, 1.0, 42); + HashMap result = runAndGetResult(testName, dml, epsilonStr, data); + + // The noisy result should be a (1 × cols) row vector. + int maxRow = 0, maxCol = 0; + for (CellIndex ci : result.keySet()) { + maxRow = Math.max(maxRow, ci.row); + maxCol = Math.max(maxCol, ci.column); + } + assertTrue("Result should have 1 row", maxRow == 1); + assertTrue("Result should have " + COLS + " columns", maxCol == COLS); + // Must differ from the exact mean by a non-trivial amount. + // (A single-seed exact-equality check is fragile; use range check.) + double maxNoise = maxAbsValue(result); + assertTrue("Result should not be exactly zero", maxNoise > 0); + } + + private double maxAbsDiff(String testName, String dml, String epsilonStr) { + double[][] data = TestUtils.generateTestMatrix(ROWS, COLS, 0, 1, 1.0, 42); + HashMap result = runAndGetResult(testName, dml, epsilonStr, data); + + double maxDiff = 0; + for (int c = 0; c < COLS; c++) { + double sum = 0; + for (int r = 0; r < ROWS; r++) + sum += data[r][c]; + double cleanMean = sum / ROWS; + double noisy = result.get(new CellIndex(1, c + 1)); + maxDiff = Math.max(maxDiff, Math.abs(noisy - cleanMean)); + } + return maxDiff; + } + + private HashMap runAndGetResult(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") }; + runTest(true, false, null, -1); + return readDMLMatrixFromOutputDir("result"); + } + + private static double maxAbsValue(HashMap m) { + double max = 0; + for (double v : m.values()) + max = Math.max(max, Math.abs(v)); + return max; + } +} + From 7334f4af16da8937bf80ac56382fac7362069dc7 Mon Sep 17 00:00:00 2001 From: Maya Anderson Date: Tue, 7 Jul 2026 00:31:26 +0200 Subject: [PATCH 10/43] Update test comments --- .../cp/DPBuiltinCPInstructionTest.java | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) 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 index 14c11567cef..750451b2991 100755 --- a/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java +++ b/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java @@ -154,7 +154,7 @@ public void testHigherEpsilonCostMoreForLaplace() { acc1.totalEpsilonSpent() < acc2.totalEpsilonSpent()); } - // --- Item 1: constructor error paths ------------------------------------ + // --- Constructor error paths ------------------------------------ @Test(expected = DMLRuntimeException.class) public void testConstructorRejectsZeroEpsilonBudget() { @@ -176,7 +176,7 @@ public void testConstructorRejectsDeltaOne() { new DPBudgetAccountant(1.0, 1.0); } - // --- Item 2: single-argument convenience constructor ------------------- + // --- Single-argument convenience constructor ------------------- @Test public void testConvenienceConstructorDefaultsDeltaTo1e5() { @@ -191,8 +191,6 @@ public void testConvenienceConstructorDefaultsDeltaTo1e5() { twoArg.totalEpsilonSpent(), oneArg.totalEpsilonSpent(), EPS); } - // --- Item 3: budget exhaustion via Gaussian releases ------------------- - @Test(expected = DMLRuntimeException.class) public void testGaussianBudgetExhaustionThrows() { // Budget = 0.1. Each Gaussian release costs more than 0.005, so 20 @@ -203,8 +201,6 @@ public void testGaussianBudgetExhaustionThrows() { } } - // --- Item 4: mixed Laplace + Gaussian composition ---------------------- - @Test public void testMixedCompositionExceedsEitherAlone() { // Compose one Laplace and one Gaussian release. The total cost must @@ -226,7 +222,7 @@ public void testMixedCompositionExceedsEitherAlone() { mixed.totalEpsilonSpent() > gauOnly.totalEpsilonSpent()); } - // --- Item 6: release count across multiple mixed releases -------------- + // --- Release count across multiple mixed releases -------------- @Test public void testReleaseCountTracksAllReleases() { @@ -242,7 +238,7 @@ public void testReleaseCountTracksAllReleases() { assertEquals(5, acc.releaseCount()); } - // --- Item 8: edge-case inputs for rdpGaussian / gaussianSigma ---------- + // --- Edge-case inputs for rdpGaussian / gaussianSigma ---------- @Test public void testGaussianSensitivityCancelsInRDP() { @@ -277,11 +273,6 @@ public void testGaussianLargerEpsilonCostsMoreBudget() { // is near zero and the empirical variance matches the theoretical value // within a reasonable tolerance. // - // Note: these tests exercise the static fill* methods indirectly by - // calling the noise-generation logic via reflection or by making the - // methods package-private. The simplest approach for a student project - // is to make fillLaplaceNoise / fillGaussianNoise package-private and - // call them directly from the test (same package). @Test public void testLaplaceNoiseMeanNearZero() { From e2a70a9d58567a403507564658e5dfc3e89d7e5d Mon Sep 17 00:00:00 2001 From: Maya Anderson Date: Tue, 7 Jul 2026 11:18:01 +0200 Subject: [PATCH 11/43] Fix integration test to compare clean to noisy data --- .../functions/privacy/dp/DPBuiltinDMLTest.java | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) 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 index df799d59c63..c5ad6d78b63 100644 --- 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 @@ -81,16 +81,19 @@ private void runDPTest(String testName, String dml, String epsilonStr) { } assertTrue("Result should have 1 row", maxRow == 1); assertTrue("Result should have " + COLS + " columns", maxCol == COLS); - // Must differ from the exact mean by a non-trivial amount. + // Must differ from the exact (clean) mean by a non-trivial amount. // (A single-seed exact-equality check is fragile; use range check.) - double maxNoise = maxAbsValue(result); - assertTrue("Result should not be exactly zero", maxNoise > 0); + double maxDiff = maxAbsDiffFromClean(data, result); + assertTrue("Result should differ from the clean mean", maxDiff > 0); } private double maxAbsDiff(String testName, String dml, String epsilonStr) { double[][] data = TestUtils.generateTestMatrix(ROWS, COLS, 0, 1, 1.0, 42); HashMap result = runAndGetResult(testName, dml, epsilonStr, data); + return maxAbsDiffFromClean(data, result); + } + private static double maxAbsDiffFromClean(double[][] data, HashMap result) { double maxDiff = 0; for (int c = 0; c < COLS; c++) { double sum = 0; @@ -123,12 +126,5 @@ private HashMap runAndGetResult(String testName, String dml, runTest(true, false, null, -1); return readDMLMatrixFromOutputDir("result"); } - - private static double maxAbsValue(HashMap m) { - double max = 0; - for (double v : m.values()) - max = Math.max(max, Math.abs(v)); - return max; - } } From 14df339d15b55eb528dd413246cff3b3fbdc7cfb Mon Sep 17 00:00:00 2001 From: Maya Anderson Date: Tue, 7 Jul 2026 15:56:42 +0200 Subject: [PATCH 12/43] Make integration test more readable --- .../functions/privacy/dp/DPBuiltinDMLTest.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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 index c5ad6d78b63..048c9501e21 100644 --- 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 @@ -1,5 +1,5 @@ // ========================================================================== -// 3. DML integration test skeleton +// DML integration test // ========================================================================== // // Full integration tests extend AutomatedTestBase and drive the DML runner. @@ -60,12 +60,13 @@ public void testGaussianOutputDiffersFromCleanMean() { @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 = maxAbsDiff("DPGaussian", DML_GAUSSIAN, "0.1"); - double noisyHigh = maxAbsDiff("DPGaussian", DML_GAUSSIAN, "0.5"); + 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); } @@ -83,17 +84,16 @@ private void runDPTest(String testName, String dml, String epsilonStr) { assertTrue("Result should have " + COLS + " columns", maxCol == 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); + double maxDiff = maxAbsColMeansDiffFromClean(data, result); assertTrue("Result should differ from the clean mean", maxDiff > 0); } - private double maxAbsDiff(String testName, String dml, String epsilonStr) { - double[][] data = TestUtils.generateTestMatrix(ROWS, COLS, 0, 1, 1.0, 42); + private double runAndGetMaxAbsColMeansDiffFromClean(double[][] data, String testName, String dml, String epsilonStr) { HashMap result = runAndGetResult(testName, dml, epsilonStr, data); - return maxAbsDiffFromClean(data, result); + return maxAbsColMeansDiffFromClean(data, result); } - private static double maxAbsDiffFromClean(double[][] data, HashMap result) { + private static double maxAbsColMeansDiffFromClean(double[][] data, HashMap result) { double maxDiff = 0; for (int c = 0; c < COLS; c++) { double sum = 0; From e6d87e69e28531205f0878e81f305b68eacebc67 Mon Sep 17 00:00:00 2001 From: Maya Anderson Date: Tue, 7 Jul 2026 18:29:12 +0200 Subject: [PATCH 13/43] Comment fix --- .../cp/DPBuiltinCPInstruction.java | 21 ++++++------------- .../privacy/dp/DPBudgetAccountant.java | 4 +++- 2 files changed, 9 insertions(+), 16 deletions(-) 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 index a474c075a96..01d744ee26c 100755 --- a/src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinCPInstruction.java +++ b/src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinCPInstruction.java @@ -34,7 +34,7 @@ * CP instruction for differential-privacy release of an already-computed * aggregate. * - *

    DML syntax (post-aggregate form, Option A): + *

    DML syntax (post-aggregate form): *

      *   result = dp_laplace(aggregate, sensitivity=1.0, epsilon=0.5)
      *   result = dp_gaussian(aggregate, sensitivity=1.0, epsilon=0.5, delta=1e-5)
    @@ -54,19 +54,6 @@
      * single method is replaced with a static analysis that reads the
      * sensitivity bound computed by the compiler; every other line in this class
      * stays unchanged.
    - *
    - * 

    Registration required in: - *

      - *
    • {@code org.apache.sysds.common.Builtins} – add - * {@code DP_LAPLACE("dp_laplace", false)} and - * {@code DP_GAUSSIAN("dp_gaussian", false)}
    • - *
    • {@code org.apache.sysds.runtime.instructions.CPInstructionParser} – - * add opcode-to-type mappings and a parse branch that returns a - * {@code DPBuiltinCPInstruction}
    • - *
    • {@code org.apache.sysds.runtime.controlprogram.context.ExecutionContext} - * – add {@code getDPBudgetAccountant()} returning a session-scoped - * {@link DPBudgetAccountant} (lazy-initialised field)
    • - *
    */ public class DPBuiltinCPInstruction extends ComputationCPInstruction { @@ -261,10 +248,14 @@ private MatrixBlock generateNoise( 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. - // Standard formula: sigma >= sensitivity * sqrt(2 * ln(1.25/delta)) / epsilon + // 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; 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 index ce5afcb7850..707c296906f 100644 --- a/src/main/java/org/apache/sysds/runtime/privacy/dp/DPBudgetAccountant.java +++ b/src/main/java/org/apache/sysds/runtime/privacy/dp/DPBudgetAccountant.java @@ -20,6 +20,7 @@ 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. @@ -216,6 +217,7 @@ public void compose(double epsilon, double delta, double sensitivity) { * 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]; @@ -260,7 +262,7 @@ private static double rdpGaussian(double alpha, double sensitivity, double sigma /** * Gaussian noise scale σ calibrated to (ε, δ)-DP: *
    -     *   σ = Δf · sqrt(2 · ln(1.25 / δ)) / ε
    +     *   σ = Δ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. From 33a15fa7780eb73779c60cde8fbe319728d19b91 Mon Sep 17 00:00:00 2001 From: Maya Anderson Date: Tue, 7 Jul 2026 18:58:50 +0200 Subject: [PATCH 14/43] Fix L1 L2 sensitivity parameter documentation --- .../cp/DPBuiltinCPInstruction.java | 27 +++++++++++++++---- .../privacy/dp/DPBudgetAccountant.java | 14 ++++++++-- 2 files changed, 34 insertions(+), 7 deletions(-) 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 index 01d744ee26c..63938f21881 100755 --- a/src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinCPInstruction.java +++ b/src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinCPInstruction.java @@ -40,6 +40,16 @@ * result = dp_gaussian(aggregate, sensitivity=1.0, epsilon=0.5, delta=1e-5) *
    * + *

    Sensitivity norm: {@code sensitivity} is not interchangeable + * between the two builtins. {@code dp_laplace} calibrates its noise scale + * to the L1 sensitivity of {@code aggregate} to a single-record + * change; {@code dp_gaussian} calibrates its σ to the L2 sensitivity. + * For a scalar aggregate (e.g. a single sum or mean) the two norms coincide, + * but for a vector-valued aggregate (e.g. column means of a multi-column + * matrix) they generally differ (L2 ≤ L1 ≤ √d·L2 for d entries) — the caller + * is responsible for supplying the norm matching the builtin invoked (see + * {@link #sensitivityOf}). + * *

    The instruction receives a materialised matrix (the aggregate result), * injects calibrated noise element-wise, records the release with the * session-scoped {@link DPBudgetAccountant}, and returns the noisy matrix. @@ -202,19 +212,26 @@ public void processInstruction(ExecutionContext ec) { // ----------------------------------------------------------------------- /** - * Returns the sensitivity of {@code aggregate} to a single-record change. + * Returns the sensitivity of {@code aggregate} 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 {@code aggregate} is scalar. * *

    Phase 1 (now): returns the caller-supplied literal from the - * DML script. Sensitivity analysis is the caller's responsibility. + * 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. Sensitivity analysis is the caller's responsibility. * *

    Phase 2 (HOP-level rewrite pass): replace this body with a * call that inspects the HOP node that produced {@code aggregate}, reads - * the {@code sensitivityBound} field computed during compilation, and - * returns it. No other line in this class changes. + * the {@code sensitivityBound} field computed during compilation (in the + * norm matching {@code instOpcode}), and returns it. No other line in + * this class changes. * * @param aggregate the already-computed aggregate block (ignored in * Phase 1; used in Phase 2 to look up lineage) - * @return caller-supplied sensitivity constant + * @return caller-supplied sensitivity constant, expected to already be + * in the L1 norm (Laplace) or L2 norm (Gaussian) */ private double sensitivityOf(MatrixBlock aggregate) { // Phase 1: unwrap the literal or variable value from the param map. 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 index 707c296906f..9dd011fcb1e 100644 --- a/src/main/java/org/apache/sysds/runtime/privacy/dp/DPBudgetAccountant.java +++ b/src/main/java/org/apache/sysds/runtime/privacy/dp/DPBudgetAccountant.java @@ -39,7 +39,8 @@ * 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 δ). + * 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 @@ -178,7 +179,16 @@ public DPBudgetAccountant(double epsilonBudget) { * * @param epsilon per-release ε parameter (must be > 0) * @param delta per-release δ parameter (0 for Laplace, >0 for Gaussian) - * @param sensitivity L2 sensitivity Δf of the released quantity (must be > 0) + * @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 */ From 67d3b1f37c32b10b6ad9287f393847e9a04d1aaf Mon Sep 17 00:00:00 2001 From: Maya Anderson Date: Tue, 7 Jul 2026 23:30:17 +0200 Subject: [PATCH 15/43] junit output format --- pom.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/pom.xml b/pom.xml index 2560a38b7b7..22dbc5c2b9e 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} From b75d85bcac6e880140dcb1753793b3a376c0515d Mon Sep 17 00:00:00 2001 From: Maya Anderson Date: Thu, 9 Jul 2026 23:35:44 +0200 Subject: [PATCH 16/43] dp_laplace and dp_gaussian now take the original matrix and build a transformation matrix T internally, returning T %*% X with noise fused into a single matrix multiply. --- .../sysds/hops/ParameterizedBuiltinOp.java | 10 +- .../parser/BuiltinFunctionExpression.java | 50 +++- .../apache/sysds/parser/DMLTranslator.java | 16 +- .../cp/DPBuiltinCPInstruction.java | 234 ++++++++++++------ .../privacy/dp/DPBuiltinDMLTest.java | 103 ++++++-- 5 files changed, 297 insertions(+), 116 deletions(-) diff --git a/src/main/java/org/apache/sysds/hops/ParameterizedBuiltinOp.java b/src/main/java/org/apache/sysds/hops/ParameterizedBuiltinOp.java index 7761521e415..3997d3402c8 100644 --- a/src/main/java/org/apache/sysds/hops/ParameterizedBuiltinOp.java +++ b/src/main/java/org/apache/sysds/hops/ParameterizedBuiltinOp.java @@ -691,8 +691,14 @@ else if( _op == ParamBuiltinOp.TRANSFORMAPPLY ) { } } else if( _op == ParamBuiltinOp.DP_LAPLACE || _op == ParamBuiltinOp.DP_GAUSSIAN ) { - if( dc.dimsKnown() ) - ret = new MatrixCharacteristics(dc.getRows(), dc.getCols(), -1, dc.getLength()); + 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; diff --git a/src/main/java/org/apache/sysds/parser/BuiltinFunctionExpression.java b/src/main/java/org/apache/sysds/parser/BuiltinFunctionExpression.java index 1425a794575..a75e5ca7b8f 100644 --- a/src/main/java/org/apache/sysds/parser/BuiltinFunctionExpression.java +++ b/src/main/java/org/apache/sysds/parser/BuiltinFunctionExpression.java @@ -2006,28 +2006,34 @@ else if(this.getOpCode() == Builtins.MAX_POOL || this.getOpCode() == Builtins.AV else raiseValidateError("Local instruction not allowed in dml script"); case DP_LAPLACE: { - checkNumParameters(3); + 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( - getFirstExpr().getOutput().getDim1(), - getFirstExpr().getOutput().getDim2()); + output.setDimensions(dpLaplaceDims[0], dpLaplaceDims[1]); break; } case DP_GAUSSIAN: { - checkNumParameters(4); + 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( - getFirstExpr().getOutput().getDim1(), - getFirstExpr().getOutput().getDim2()); + output.setDimensions(dpGaussianDims[0], dpGaussianDims[1]); break; } case COMPRESS: @@ -2136,6 +2142,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/DMLTranslator.java b/src/main/java/org/apache/sysds/parser/DMLTranslator.java index 7950868e0b5..06df5fec357 100644 --- a/src/main/java/org/apache/sysds/parser/DMLTranslator.java +++ b/src/main/java/org/apache/sysds/parser/DMLTranslator.java @@ -2314,6 +2314,10 @@ private Hop processBuiltinFunctionExpression(BuiltinFunctionExpression source, D if (source.getFourthExpr() != null) { expr4 = processExpression(source.getFourthExpr(), null, hops); } + Hop expr5 = null; + if (source.getFifthExpr() != null) { + expr5 = processExpression(source.getFifthExpr(), null, hops); + } Hop currBuiltinOp = null; target = (target == null) ? createTarget(source) : target; @@ -2596,8 +2600,9 @@ else if ( sop.equalsIgnoreCase(Opcodes.NOTEQUAL.toString()) ) case DP_LAPLACE: { LinkedHashMap dpLaplaceParams = new LinkedHashMap<>(); dpLaplaceParams.put("target", expr); - dpLaplaceParams.put("sensitivity", expr2); - dpLaplaceParams.put("epsilon", expr3); + dpLaplaceParams.put("query", expr2); + dpLaplaceParams.put("sensitivity", expr3); + dpLaplaceParams.put("epsilon", expr4); currBuiltinOp = new ParameterizedBuiltinOp(target.getName(), DataType.MATRIX, ValueType.FP64, ParamBuiltinOp.DP_LAPLACE, dpLaplaceParams); break; @@ -2605,9 +2610,10 @@ else if ( sop.equalsIgnoreCase(Opcodes.NOTEQUAL.toString()) ) case DP_GAUSSIAN: { LinkedHashMap dpGaussianParams = new LinkedHashMap<>(); dpGaussianParams.put("target", expr); - dpGaussianParams.put("sensitivity", expr2); - dpGaussianParams.put("epsilon", expr3); - dpGaussianParams.put("delta", expr4); + dpGaussianParams.put("query", expr2); + dpGaussianParams.put("sensitivity", expr3); + dpGaussianParams.put("epsilon", expr4); + dpGaussianParams.put("delta", expr5); currBuiltinOp = new ParameterizedBuiltinOp(target.getName(), DataType.MATRIX, ValueType.FP64, ParamBuiltinOp.DP_GAUSSIAN, dpGaussianParams); break; 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 index 63938f21881..9d49f959e13 100755 --- a/src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinCPInstruction.java +++ b/src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinCPInstruction.java @@ -21,49 +21,49 @@ import org.apache.sysds.runtime.DMLRuntimeException; import org.apache.sysds.runtime.controlprogram.context.ExecutionContext; -import org.apache.sysds.runtime.functionobjects.Plus; 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.matrix.operators.BinaryOperator; import org.apache.sysds.runtime.privacy.dp.DPBudgetAccountant; import java.util.LinkedHashMap; import java.util.concurrent.ThreadLocalRandom; /** - * CP instruction for differential-privacy release of an already-computed - * aggregate. + * CP instruction for differential-privacy release of a linear query over the + * original matrix. * - *

    DML syntax (post-aggregate form): + *

    DML syntax (raw-matrix form): *

    - *   result = dp_laplace(aggregate, sensitivity=1.0, epsilon=0.5)
    - *   result = dp_gaussian(aggregate, sensitivity=1.0, epsilon=0.5, delta=1e-5)
    + *   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 aggregate} to a single-record + * 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 aggregate (e.g. a single sum or mean) the two norms coincide, - * but for a vector-valued aggregate (e.g. column means of a multi-column - * matrix) they generally differ (L2 ≤ L1 ≤ √d·L2 for d entries) — the caller - * is responsible for supplying the norm matching the builtin invoked (see - * {@link #sensitivityOf}). - * - *

    The instruction receives a materialised matrix (the aggregate result), - * injects calibrated noise element-wise, records the release with the - * session-scoped {@link DPBudgetAccountant}, and returns the noisy matrix. - * - *

    Noise is generated in Java and added via a {@code MatrixBlock} binary - * operation so that the output allocation path is identical to every other - * CP instruction (no special memory-management required). + * 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. In Phase 1 it returns the caller-supplied - * constant. In the future HOP-level rewrite pass (Phase 2) the body of this - * single method is replaced with a static analysis that reads the - * sensitivity bound computed by the compiler; every other line in this class - * stays unchanged. + * 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 { @@ -81,7 +81,7 @@ public class DPBuiltinCPInstruction extends ComputationCPInstruction { /** * Named parameters extracted from the serialised instruction string. - * Keys: "target", "sensitivity", "epsilon", "delta" (Gaussian only). + * Keys: "target", "query", "sensitivity", "epsilon", "delta" (Gaussian only). * * Using the same LinkedHashMap convention as * ParameterizedBuiltinCPInstruction so that CPInstructionParser can @@ -113,9 +113,9 @@ private DPBuiltinCPInstruction( * *

    Expected format (OPERAND_DELIM = '\u00b0'): *

    -     *   dp_gaussian°target=mVar1·MATRIX·FP64°sensitivity=1.0·SCALAR·FP64·true
    -     *              °epsilon=0.5·SCALAR·FP64·true°delta=1e-5·SCALAR·FP64·true
    -     *              °_mVar2·MATRIX·FP64
    +     *   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 @@ -124,7 +124,7 @@ private DPBuiltinCPInstruction( */ public static DPBuiltinCPInstruction parseInstruction(String str) { String[] parts = InstructionUtils.getInstructionPartsWithValueType(str); - InstructionUtils.checkNumFields(parts, 4, 5); // laplace=4, gaussian=5 + InstructionUtils.checkNumFields(parts, 5, 6); // laplace=5, gaussian=6 String opcode = parts[0]; // Output operand is always the last token. @@ -143,6 +143,8 @@ public static DPBuiltinCPInstruction parseInstruction(String str) { 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")) @@ -161,81 +163,162 @@ public static DPBuiltinCPInstruction parseInstruction(String str) { * Executes the DP release. * *
      - *
    1. Read the aggregate {@link MatrixBlock} from the variable table.
    2. - *
    3. Determine sensitivity via {@link #sensitivityOf} (Phase-1 stub).
    4. - *
    5. Generate a noise {@link MatrixBlock} of the same shape.
    6. - *
    7. Add noise element-wise using the existing binary-operator path.
    8. + *
    9. Read the original {@link MatrixBlock} {@code X} from the variable + * table.
    10. + *
    11. Build the transformation matrix {@code T} ({@code k x n}) from + * {@code query} (see {@link #buildTransform}).
    12. + *
    13. Determine sensitivity via {@link #sensitivityOf}.
    14. + *
    15. Generate a noise {@link MatrixBlock} shaped {@code k x d}.
    16. + *
    17. Fuse {@code T %*% X + noise} into a single + * {@link LibMatrixMult#matrixMult} call (see below).
    18. *
    19. Record the release with the session-scoped * {@link DPBudgetAccountant}; throw if budget is exhausted.
    20. *
    21. Write the noisy block back to the variable table and release * the input pin.
    22. *
    + * + *

    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 aggregate input ───────────────────────────────────────── + // ── 1. Read original input matrix X ───────────────────────────────── // getMatrixInput pins the block in memory and increments the // reference count; we must call releaseMatrixInput afterwards. - MatrixBlock inBlock = ec.getMatrixInput(_params.get("target")); + 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()); - // ── 3. Determine sensitivity (Phase-1: caller-supplied constant) ──── - double sensitivity = sensitivityOf(inBlock); + // ── 4. Determine sensitivity (caller-supplied constant) ───────────── + double sensitivity = sensitivityOf(T); - // ── 4. Generate and add noise ──────────────────────────────────────── - MatrixBlock noiseBlock = generateNoise(inBlock, sensitivity, epsilon, delta); + // ── 5. Generate noise shaped like the release T %*% X (k x d) ─────── + MatrixBlock noiseBlock = generateNoise(T.getNumRows(), X.getNumColumns(), + sensitivity, epsilon, delta); - // Element-wise addition via the standard binary-operator path. - // binaryOperations allocates the output block internally. - BinaryOperator plusOp = new BinaryOperator(Plus.getPlusFnObject()); - MatrixBlock outBlock = new MatrixBlock(); - inBlock.binaryOperations(plusOp, noiseBlock, outBlock); + // ── 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); - // ── 5. Record release and enforce budget ──────────────────────────── + // ── 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 - // ── 6. Write output and release input pin ─────────────────────────── + // ── 8. Write output and release input pin ─────────────────────────── ec.releaseMatrixInput(_params.get("target")); ec.setMatrixOutput(output.getName(), outBlock); } // ----------------------------------------------------------------------- - // Sensitivity seam (Phase-1 stub; Phase-2 replaces this body only) + // Transformation matrix construction // ----------------------------------------------------------------------- /** - * Returns the sensitivity of {@code aggregate} 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 {@code aggregate} is scalar. + * 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}. * - *

    Phase 1 (now): 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. Sensitivity analysis is the caller's responsibility. + *

      + *
    • {@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. * - *

    Phase 2 (HOP-level rewrite pass): replace this body with a - * call that inspects the HOP node that produced {@code aggregate}, reads - * the {@code sensitivityBound} field computed during compilation (in the - * norm matching {@code instOpcode}), and returns it. No other line in - * this class changes. + *

    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 aggregate the already-computed aggregate block (ignored in - * Phase 1; used in Phase 2 to look up lineage) + * @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 aggregate) { - // Phase 1: unwrap the literal or variable value from the param map. - // In Phase 2, replace the body below with HOP-annotation lookup. + private double sensitivityOf(MatrixBlock T) { return parsePositiveDouble("sensitivity"); } @@ -244,23 +327,22 @@ private double sensitivityOf(MatrixBlock aggregate) { // ----------------------------------------------------------------------- /** - * Generates a noise {@link MatrixBlock} of the same shape as - * {@code aggregate}, filled with samples from the mechanism-appropriate - * distribution calibrated to ({@code sensitivity}, {@code epsilon}, - * {@code delta}). + * 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 aggregate outputs targeted here (e.g. - * column means, row sums) the aggregate is already dense. + * left for future work; for the releases targeted here (e.g. column + * means, column sums) the noise is dense regardless. */ private MatrixBlock generateNoise( - MatrixBlock aggregate, + int rows, + int cols, double sensitivity, double epsilon, double delta) { - int rows = aggregate.getNumRows(); - int cols = aggregate.getNumColumns(); MatrixBlock noise = new MatrixBlock(rows, cols, false); // dense noise.allocateDenseBlock(); 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 index 048c9501e21..32a02045753 100644 --- 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 @@ -14,6 +14,7 @@ package org.apache.sysds.test.functions.privacy.dp; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import java.io.File; @@ -33,15 +34,19 @@ public class DPBuiltinDMLTest extends AutomatedTestBase { 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 = + + private static final String DML_LAPLACE_TEMPLATE = "X = read($1);\n" - + "result = dp_laplace(colMeans(X), sensitivity=1.0, epsilon=$2);\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 = + private static final String DML_GAUSSIAN_TEMPLATE = "X = read($1);\n" - + "result = dp_gaussian(colMeans(X), sensitivity=1.0, epsilon=$2, delta=1e-5);\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"); + @Override public void setUp() { addTestConfiguration("DPLaplace", new TestConfiguration(TEST_CLASS, "DPLaplace")); @@ -50,17 +55,46 @@ public void setUp() { @Test public void testLaplaceOutputDiffersFromCleanMean() { - runDPTest("DPLaplace", DML_LAPLACE, "0.5"); + runColMeansDPTest("DPLaplace", DML_LAPLACE, "0.5"); } @Test public void testGaussianOutputDiffersFromCleanMean() { - runDPTest("DPGaussian", DML_GAUSSIAN, "0.5"); + 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); + 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 @@ -70,42 +104,62 @@ public void testHighEpsilonIsCloserToTruth() { assertTrue("ε=0.5 should give less noise than ε=0.1", noisyHigh < noisyLow); } - private void runDPTest(String testName, String dml, String epsilonStr) { + 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); + } - // The noisy result should be a (1 × cols) row vector. + 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); } - assertTrue("Result should have 1 row", maxRow == 1); - assertTrue("Result should have " + COLS + " columns", maxCol == 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 = maxAbsColMeansDiffFromClean(data, result); - assertTrue("Result should differ from the clean mean", maxDiff > 0); + assertEquals("Result should have " + expectedRows + " row(s)", expectedRows, maxRow); + assertEquals("Result should have " + expectedCols + " column(s)", expectedCols, maxCol); } - private double runAndGetMaxAbsColMeansDiffFromClean(double[][] data, String testName, String dml, String epsilonStr) { - HashMap result = runAndGetResult(testName, dml, epsilonStr, data); - return maxAbsColMeansDiffFromClean(data, result); + @FunctionalInterface + private interface CleanColumnFn { + double apply(double[][] data, int col); } - private static double maxAbsColMeansDiffFromClean(double[][] data, HashMap result) { + /** 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 sum = 0; - for (int r = 0; r < ROWS; r++) - sum += data[r][c]; - double cleanMean = sum / ROWS; + double clean = cleanFn.apply(data, c); double noisy = result.get(new CellIndex(1, c + 1)); - maxDiff = Math.max(maxDiff, Math.abs(noisy - cleanMean)); + 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) { @@ -127,4 +181,3 @@ private HashMap runAndGetResult(String testName, String dml, return readDMLMatrixFromOutputDir("result"); } } - From 467144c61a728a8b71348b06ac30f6e4c1098afe Mon Sep 17 00:00:00 2001 From: Maya Anderson Date: Mon, 13 Jul 2026 14:48:48 +0200 Subject: [PATCH 17/43] Remove 4th and 5th parameters and instead get them in a loop, according to David's suggestion --- .../apache/sysds/parser/DMLTranslator.java | 41 +++++++++---------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/src/main/java/org/apache/sysds/parser/DMLTranslator.java b/src/main/java/org/apache/sysds/parser/DMLTranslator.java index 06df5fec357..6b51c75b4d5 100644 --- a/src/main/java/org/apache/sysds/parser/DMLTranslator.java +++ b/src/main/java/org/apache/sysds/parser/DMLTranslator.java @@ -2310,14 +2310,6 @@ private Hop processBuiltinFunctionExpression(BuiltinFunctionExpression source, D if (source.getThirdExpr() != null) { expr3 = processExpression(source.getThirdExpr(), null, hops); } - Hop expr4 = null; - if (source.getFourthExpr() != null) { - expr4 = processExpression(source.getFourthExpr(), null, hops); - } - Hop expr5 = null; - if (source.getFifthExpr() != null) { - expr5 = processExpression(source.getFifthExpr(), null, hops); - } Hop currBuiltinOp = null; target = (target == null) ? createTarget(source) : target; @@ -2598,24 +2590,31 @@ else if ( sop.equalsIgnoreCase(Opcodes.NOTEQUAL.toString()) ) 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("target", expr); - dpLaplaceParams.put("query", expr2); - dpLaplaceParams.put("sensitivity", expr3); - dpLaplaceParams.put("epsilon", expr4); - currBuiltinOp = new ParameterizedBuiltinOp(target.getName(), DataType.MATRIX, - ValueType.FP64, ParamBuiltinOp.DP_LAPLACE, dpLaplaceParams); + 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("target", expr); - dpGaussianParams.put("query", expr2); - dpGaussianParams.put("sensitivity", expr3); - dpGaussianParams.put("epsilon", expr4); - dpGaussianParams.put("delta", expr5); - currBuiltinOp = new ParameterizedBuiltinOp(target.getName(), DataType.MATRIX, - ValueType.FP64, ParamBuiltinOp.DP_GAUSSIAN, dpGaussianParams); + 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 QUANTIZE_COMPRESS: From 74d10b9ae0b9ee20bc575dfb8e542cbded328d4e Mon Sep 17 00:00:00 2001 From: Maya Anderson Date: Tue, 14 Jul 2026 14:20:33 +0200 Subject: [PATCH 18/43] Add dp_set_budget(epsilon, delta) built-in Lets a DML script declare its session-wide differential-privacy budget once at the top, instead of always falling back to the hardcoded default. Resolved entirely at compile time: epsilon/delta must be literals, validated in BuiltinFunctionExpression and stored on DMLProgram during HOP construction, then read by ExecutionContext.getDPBudgetAccountant(). --- .../org/apache/sysds/common/Builtins.java | 1 + .../parser/BuiltinFunctionExpression.java | 22 +++++ .../org/apache/sysds/parser/DMLProgram.java | 33 +++++++- .../apache/sysds/parser/DMLTranslator.java | 19 +++++ .../context/ExecutionContext.java | 16 +++- .../cp/DPBuiltinCPInstructionTest.java | 58 +++++++++++++ .../privacy/dp/DPBuiltinDMLTest.java | 81 ++++++++++++++++++- 7 files changed, 225 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/apache/sysds/common/Builtins.java b/src/main/java/org/apache/sysds/common/Builtins.java index be6ee0f33db..6dce5fb30eb 100644 --- a/src/main/java/org/apache/sysds/common/Builtins.java +++ b/src/main/java/org/apache/sysds/common/Builtins.java @@ -118,6 +118,7 @@ public enum Builtins { 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/parser/BuiltinFunctionExpression.java b/src/main/java/org/apache/sysds/parser/BuiltinFunctionExpression.java index a75e5ca7b8f..4fc43e0c89b 100644 --- a/src/main/java/org/apache/sysds/parser/BuiltinFunctionExpression.java +++ b/src/main/java/org/apache/sysds/parser/BuiltinFunctionExpression.java @@ -2036,6 +2036,28 @@ else if(this.getOpCode() == Builtins.MAX_POOL || this.getOpCode() == Builtins.AV 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){ 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 6b51c75b4d5..a9d5279c26b 100644 --- a/src/main/java/org/apache/sysds/parser/DMLTranslator.java +++ b/src/main/java/org/apache/sysds/parser/DMLTranslator.java @@ -2617,6 +2617,25 @@ else if ( sop.equalsIgnoreCase(Opcodes.NOTEQUAL.toString()) ) 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 eaf50da88c7..bce0f2f679a 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; @@ -147,9 +148,20 @@ 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 default. + */ public DPBudgetAccountant getDPBudgetAccountant() { - if (_dpBudgetAccountant == null) - _dpBudgetAccountant = new DPBudgetAccountant(1.0, 1e-5); + if (_dpBudgetAccountant == null) { + DMLProgram dmlProg = (_prog != null) ? _prog.getDMLProg() : null; + _dpBudgetAccountant = (dmlProg != null && dmlProg.hasDPBudget()) + ? new DPBudgetAccountant(dmlProg.getDPBudgetEpsilon(), dmlProg.getDPBudgetDelta()) + : new DPBudgetAccountant(1.0, 1e-5); + } return _dpBudgetAccountant; } 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 index 750451b2991..132ce516088 100755 --- a/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java +++ b/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java @@ -19,7 +19,11 @@ 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; @@ -176,6 +180,60 @@ 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 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 index 32a02045753..9b2ef30e0af 100644 --- 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 @@ -22,6 +22,9 @@ 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; @@ -47,10 +50,38 @@ public class DPBuiltinDMLTest extends AutomatedTestBase { 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 @@ -104,6 +135,41 @@ public void testHighEpsilonIsCloserToTruth() { 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); @@ -163,6 +229,19 @@ private static double colSum(double[][] data, int c) { 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); @@ -177,7 +256,5 @@ private HashMap runAndGetResult(String testName, String dml, } programArgs = new String[]{ "-args", input("X"), epsilonStr, output("result") }; - runTest(true, false, null, -1); - return readDMLMatrixFromOutputDir("result"); } } From dc0dbf29979502a5792de010ee88b25b88e29fef Mon Sep 17 00:00:00 2001 From: Maya Anderson Date: Tue, 14 Jul 2026 14:31:21 +0200 Subject: [PATCH 19/43] Change DPBudgetAccountant defaults to be constants --- .../sysds/parser/BuiltinFunctionExpression.java | 4 ++-- .../controlprogram/context/ExecutionContext.java | 4 ++-- .../sysds/runtime/privacy/dp/DPBudgetAccountant.java | 12 ++++++++++++ 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/apache/sysds/parser/BuiltinFunctionExpression.java b/src/main/java/org/apache/sysds/parser/BuiltinFunctionExpression.java index 4fc43e0c89b..1c2c6ba8110 100644 --- a/src/main/java/org/apache/sysds/parser/BuiltinFunctionExpression.java +++ b/src/main/java/org/apache/sysds/parser/BuiltinFunctionExpression.java @@ -2047,10 +2047,10 @@ else if(this.getOpCode() == Builtins.MAX_POOL || this.getOpCode() == Builtins.AV false, LanguageErrorCodes.INVALID_PARAMETERS); double dpSetBudgetEpsilon = getDoubleValue(getFirstExpr()); double dpSetBudgetDelta = getDoubleValue(getSecondExpr()); - if (!(dpSetBudgetEpsilon > 0)) + if (dpSetBudgetEpsilon <= 0) raiseValidateError(getOpCode() + ": epsilon must be > 0, got " + dpSetBudgetEpsilon, false, LanguageErrorCodes.INVALID_PARAMETERS); - if (!(dpSetBudgetDelta > 0 && dpSetBudgetDelta < 1)) + if ((dpSetBudgetDelta <= 0) || (dpSetBudgetDelta >= 1)) raiseValidateError(getOpCode() + ": delta must be in (0,1), got " + dpSetBudgetDelta, false, LanguageErrorCodes.INVALID_PARAMETERS); output.setDataType(DataType.SCALAR); 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 bce0f2f679a..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 @@ -153,14 +153,14 @@ public void setLineage(Lineage lineage) { * 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 default. + * {@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(1.0, 1e-5); + : new DPBudgetAccountant(); } return _dpBudgetAccountant; } 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 index 9dd011fcb1e..51cd48dee4d 100644 --- a/src/main/java/org/apache/sysds/runtime/privacy/dp/DPBudgetAccountant.java +++ b/src/main/java/org/apache/sysds/runtime/privacy/dp/DPBudgetAccountant.java @@ -91,6 +91,10 @@ 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 @@ -160,6 +164,14 @@ 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 // ----------------------------------------------------------------------- From a62a1e531e06f9a3d78c3e951b3cbdd62aaef956 Mon Sep 17 00:00:00 2001 From: Maya Anderson Date: Tue, 14 Jul 2026 23:54:43 +0200 Subject: [PATCH 20/43] Cleanup comments --- .../cp/DPBuiltinCPInstruction.java | 74 +++++++++---------- .../privacy/dp/DPBudgetAccountant.java | 62 ++++++---------- .../cp/DPBuiltinCPInstructionTest.java | 18 ++--- 3 files changed, 63 insertions(+), 91 deletions(-) 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 index 9d49f959e13..1ca7d75bf86 100755 --- a/src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinCPInstruction.java +++ b/src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinCPInstruction.java @@ -34,31 +34,29 @@ * CP instruction for differential-privacy release of a linear query over the * original matrix. * - *

    DML syntax (raw-matrix form): - *

    + * 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}, + * 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 + * {@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 + * 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. + * 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 + * 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 @@ -111,12 +109,10 @@ private DPBuiltinCPInstruction( * Reconstructs a {@code DPBuiltinCPInstruction} from its serialised * instruction string produced by the LOP layer. * - *

    Expected format (OPERAND_DELIM = '\u00b0'): - *

    +     * 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 @@ -162,22 +158,20 @@ public static DPBuiltinCPInstruction parseInstruction(String str) { /** * Executes the DP release. * - *
      - *
    1. Read the original {@link MatrixBlock} {@code X} from the variable - * table.
    2. - *
    3. Build the transformation matrix {@code T} ({@code k x n}) from - * {@code query} (see {@link #buildTransform}).
    4. - *
    5. Determine sensitivity via {@link #sensitivityOf}.
    6. - *
    7. Generate a noise {@link MatrixBlock} shaped {@code k x d}.
    8. - *
    9. Fuse {@code T %*% X + noise} into a single - * {@link LibMatrixMult#matrixMult} call (see below).
    10. - *
    11. Record the release with the session-scoped - * {@link DPBudgetAccountant}; throw if budget is exhausted.
    12. - *
    13. Write the noisy block back to the variable table and release - * the input pin.
    14. - *
    + * - 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} + * 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 @@ -235,17 +229,15 @@ public void processInstruction(ExecutionContext ec) { * 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 + * - {@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.
    • - *
    + * {@code X} itself, i.e. a noisy release of the raw matrix. * - *

    Row-wise aggregates ({@code rowMeans}/{@code rowSums}) reduce across + * 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. @@ -302,11 +294,11 @@ private static MatrixBlock identity(int k) { /** * 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 + * 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 + * 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 @@ -332,7 +324,7 @@ private double sensitivityOf(MatrixBlock T) { * mechanism-appropriate distribution calibrated to ({@code sensitivity}, * {@code epsilon}, {@code delta}). * - *

    Both mechanisms produce a dense block. Sparsity exploitation is + * 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. */ @@ -369,7 +361,7 @@ private MatrixBlock generateNoise( * 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|) + * 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(); @@ -390,7 +382,7 @@ private static void fillLaplaceNoise(MatrixBlock block, double scale) { /** * Fills {@code block} with i.i.d. N(0, sigma²) samples. * - *

    Uses {@link ThreadLocalRandom#nextGaussian()} which is thread-safe + * Uses {@link ThreadLocalRandom#nextGaussian()} which is thread-safe * and does not require external libraries. */ private static void fillGaussianNoise(MatrixBlock block, double sigma) { 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 index 51cd48dee4d..11ddc3ac79f 100644 --- a/src/main/java/org/apache/sysds/runtime/privacy/dp/DPBudgetAccountant.java +++ b/src/main/java/org/apache/sysds/runtime/privacy/dp/DPBudgetAccountant.java @@ -25,61 +25,49 @@ /** * Session-scoped differential privacy budget accountant. * - *

    Purpose

    * 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. * - *

    Composition strategy

    * 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 + * - 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. + * 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.
    • - *
    + * in federated learning. * - *

    When both mechanisms are used in the same script the total cost is: - *

    + * 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)

    + * 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

    + * 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)

    - *
    + * RDP → (ε, δ) conversion (Mironov 2017, Proposition 3)
      *   ε(α) = R[α] + log(1 − 1/α) − log(δ·(α−1)) / α
    - * 
    * - *

    Lifecycle

    * 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. * - *

    Thread safety

    * Not thread-safe. A single DML script executes instructions sequentially * on one thread, so no synchronisation is needed. * @@ -96,7 +84,7 @@ public class DPBudgetAccountant { private static final double DEFAULT_DELTA = 1e-5; /** - * Discrete set of Rényi orders α. All must be > 1. + * 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. */ @@ -114,7 +102,7 @@ public class DPBudgetAccountant { /** * Running sum of pure ε from Laplace releases. * - *

    Laplace gives pure ε-DP (no δ). Basic composition is exact and + * 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()}. @@ -137,12 +125,12 @@ public class DPBudgetAccountant { /** * Creates an accountant with the given global budget. * - *

    Typical usage: the DML script sets the budget once at the top + * 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 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) { @@ -179,23 +167,21 @@ public DPBudgetAccountant() { /** * Records one DP release and checks the budget. * - *

    This method must be called before the result is written to + * 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
    • - *
    + * 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 + * @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 @@ -235,7 +221,7 @@ public void compose(double epsilon, double delta, double sensitivity) { /** * Returns the current total privacy cost as an ε value. * - *

    Total = Laplace pure-ε sum + Gaussian RDP-converted ε (clamped to + * Total = Laplace pure-ε sum + Gaussian RDP-converted ε (clamped to * zero when no Gaussian releases have been recorded). */ public double totalEpsilonSpent() { @@ -273,9 +259,7 @@ public int releaseCount() { /** * 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); @@ -283,9 +267,7 @@ private static double rdpGaussian(double alpha, double sensitivity, double 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. */ 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 index 132ce516088..c10dd065fd8 100755 --- a/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java +++ b/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java @@ -32,19 +32,17 @@ /** * Tests for {@code DPBuiltinCPInstruction} and {@code DPBudgetAccountant}. * - *

    The tests are grouped into three levels: - *

      - *
    1. Unit tests on DPBudgetAccountant — verify composition, conversion, + * 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.
    2. - *
    3. Noise distribution tests — verify that the noise blocks + * 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).
    4. - *
    5. DML integration tests — run complete DML scripts and verify - * end-to-end correctness via the existing AutomatedTestBase machinery.
    6. - *
    + * 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 + * 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 { From 57c59aad3078a579bc3f98450f0a56b726b23ff9 Mon Sep 17 00:00:00 2001 From: Maya Anderson Date: Tue, 14 Jul 2026 16:44:14 +0200 Subject: [PATCH 21/43] Differential Privacy Benchmark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four federated workers simulated on localhost, a logistic regression FedAvg loop in DML where the coordinator applies dp_gaussian to the aggregated gradient, a sweep over ε ∈ {0.5, 1, 4, 8} plus a non-private baseline, and a matplotlib accuracy-vs-ε plot saved as a PNG. Add clip_norm (default 4.0) as a script parameter. Inside the private == 1 branch, each row's gradient contribution is clipped to L2-norm less than clip_norm. --- benchmark/scripts/collect_results.py | 41 +++++++++ benchmark/scripts/eval.dml | 22 +++++ benchmark/scripts/fedavg_dp.dml | 130 +++++++++++++++++++++++++++ benchmark/scripts/plot.py | 114 +++++++++++++++++++++++ benchmark/scripts/prepare_data.py | 123 +++++++++++++++++++++++++ benchmark/scripts/run_benchmark.sh | 15 ++++ benchmark/scripts/run_sweep.sh | 69 ++++++++++++++ benchmark/scripts/start_workers.sh | 24 +++++ benchmark/scripts/stop_workers.sh | 11 +++ src/main/python/requirements.txt | 31 +++++++ 10 files changed, 580 insertions(+) create mode 100644 benchmark/scripts/collect_results.py create mode 100644 benchmark/scripts/eval.dml create mode 100644 benchmark/scripts/fedavg_dp.dml create mode 100644 benchmark/scripts/plot.py create mode 100644 benchmark/scripts/prepare_data.py create mode 100755 benchmark/scripts/run_benchmark.sh create mode 100755 benchmark/scripts/run_sweep.sh create mode 100755 benchmark/scripts/start_workers.sh create mode 100755 benchmark/scripts/stop_workers.sh create mode 100644 src/main/python/requirements.txt 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/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 From dda4a53178b4c36001aed25049b3dbeedb725ddd Mon Sep 17 00:00:00 2001 From: Maya Anderson Date: Tue, 21 Jul 2026 23:39:55 +0200 Subject: [PATCH 22/43] Checkstyle fix --- .../org/apache/sysds/parser/DMLProgram.java | 15 +- .../context/ExecutionContext.java | 16 +- .../instructions/cp/CPInstruction.java | 14 +- .../cp/DPBuiltinCPInstruction.java | 715 ++++++++--------- .../privacy/dp/DPBudgetAccountant.java | 436 +++++----- .../cp/DPBuiltinCPInstructionTest.java | 752 +++++++++--------- .../privacy/dp/DPBuiltinDMLTest.java | 440 +++++----- 7 files changed, 1133 insertions(+), 1255 deletions(-) diff --git a/src/main/java/org/apache/sysds/parser/DMLProgram.java b/src/main/java/org/apache/sysds/parser/DMLProgram.java index d2c0b9ae7d3..bc2278e26f6 100644 --- a/src/main/java/org/apache/sysds/parser/DMLProgram.java +++ b/src/main/java/org/apache/sysds/parser/DMLProgram.java @@ -38,15 +38,12 @@ public class DMLProgram 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. + * 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; 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 7f25b2364fe..8fc88f3b375 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 @@ -149,18 +149,16 @@ public void setLineage(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. + * 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) { + if(_dpBudgetAccountant == null) { DMLProgram dmlProg = (_prog != null) ? _prog.getDMLProg() : null; - _dpBudgetAccountant = (dmlProg != null && dmlProg.hasDPBudget()) - ? new DPBudgetAccountant(dmlProg.getDPBudgetEpsilon(), dmlProg.getDPBudgetDelta()) - : new DPBudgetAccountant(); + _dpBudgetAccountant = (dmlProg != null && dmlProg.hasDPBudget()) ? new DPBudgetAccountant( + dmlProg.getDPBudgetEpsilon(), dmlProg.getDPBudgetDelta()) : new DPBudgetAccountant(); } return _dpBudgetAccountant; } 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 668d2f36978..e45e17ac175 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 @@ -42,15 +42,11 @@ public enum CPType { AggregateUnary, AggregateBinary, AggregateTernary, Unary, Binary, Ternary, Quaternary, BuiltinNary, Ctable, MultiReturnParameterizedBuiltin, ParameterizedBuiltin, MultiReturnBuiltin, MultiReturnComplexMatrixBuiltin, - Builtin, Reorg, Variable, FCall, Append, Rand, QSort, QPick, Local, - MatrixIndexing, MMTSJ, PMMJ, MMChain, Reshape, Partition, Compression, DeCompression, SpoofFused, - StringInit, CentralMoment, Covariance, UaggOuterChain, Dnn, Sql, Prefetch, Broadcast, TrigRemote, - EvictLineageCache, EINSUM, - NoOp, - Union, - QuantizeCompression, - DPBuiltin - } + Builtin, Reorg, Variable, FCall, Append, Rand, QSort, QPick, Local, MatrixIndexing, MMTSJ, PMMJ, MMChain, + Reshape, Partition, Compression, DeCompression, SpoofFused, StringInit, CentralMoment, Covariance, + UaggOuterChain, Dnn, Sql, Prefetch, Broadcast, TrigRemote, EvictLineageCache, EINSUM, NoOp, Union, + QuantizeCompression, DPBuiltin + } protected final CPType _cptype; protected final boolean _requiresLabelUpdate; 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 index 1ca7d75bf86..3904dff1c5a 100755 --- a/src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinCPInstruction.java +++ b/src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinCPInstruction.java @@ -31,398 +31,339 @@ import java.util.concurrent.ThreadLocalRandom; /** - * CP instruction for differential-privacy release of a linear query over the - * original matrix. + * 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) + * 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). + * 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}). + * 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. + * 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; - } + // ----------------------------------------------------------------------- + // 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 index 11ddc3ac79f..b367cb7fcc3 100644 --- a/src/main/java/org/apache/sysds/runtime/privacy/dp/DPBudgetAccountant.java +++ b/src/main/java/org/apache/sysds/runtime/privacy/dp/DPBudgetAccountant.java @@ -25,253 +25,219 @@ /** * 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. + * 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}: + * 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. + * - 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 ε. + * 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 + * 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. + * 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)) / α + * 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. + * 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. + * 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; - } + // ----------------------------------------------------------------------- + // 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/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java b/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java index c10dd065fd8..0345ea73fc2 100755 --- a/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java +++ b/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java @@ -25,394 +25,384 @@ 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.*; +import org.junit.Test; +import 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 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}. + * 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); - } + 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. + Assert.assertTrue("No releases should leave budget intact", acc.remainingBudget() > 0); + Assert.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 + Assert.assertEquals(1, acc.releaseCount()); + Assert.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 + Assert.assertEquals(1, acc.releaseCount()); + Assert.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(); + Assert.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.) + Assert.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(); + Assert.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 + + Assert.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(); + Assert.assertFalse("No dp_set_budget call yet", dmlProg.hasDPBudget()); + dmlProg.setDPBudget(2.0, 1e-6); + Assert.assertTrue("dp_set_budget was called", dmlProg.hasDPBudget()); + Assert.assertEquals(2.0, dmlProg.getDPBudgetEpsilon(), EPS); + Assert.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 + Assert.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); + Assert.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); + Assert.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); + + Assert.assertTrue("Mixed cost must exceed Laplace-only cost", mixed.totalEpsilonSpent() > lapOnly.totalEpsilonSpent()); + Assert.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); + Assert.assertEquals(0, acc.releaseCount()); + acc.compose(0.1, 0.0, 1.0); // Laplace + Assert.assertEquals(1, acc.releaseCount()); + acc.compose(0.1, 1e-5, 1.0); // Gaussian + Assert.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 + Assert.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 (ε,δ) + Assert.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); + Assert.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); + Assert.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; + Assert.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); + Assert.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; + Assert.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 index 9b2ef30e0af..16dd952adf7 100644 --- 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 @@ -11,7 +11,6 @@ // statistically plausible amount (not zero, not astronomically large). // - package org.apache.sysds.test.functions.privacy.dp; import static org.junit.Assert.assertEquals; @@ -33,228 +32,219 @@ 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") }; - } + 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")}; + } } From 15eff335c518068b76f57f6cc46d8efb941feb8b Mon Sep 17 00:00:00 2001 From: Maya Anderson Date: Wed, 22 Jul 2026 00:03:23 +0200 Subject: [PATCH 23/43] Add missing licenses --- benchmark/scripts/collect_results.py | 21 +++++++++ benchmark/scripts/eval.dml | 21 +++++++++ benchmark/scripts/fedavg_dp.dml | 21 +++++++++ benchmark/scripts/plot.py | 20 +++++++++ benchmark/scripts/prepare_data.py | 20 +++++++++ benchmark/scripts/run_benchmark.sh | 21 +++++++++ benchmark/scripts/run_sweep.sh | 21 +++++++++ benchmark/scripts/start_workers.sh | 21 +++++++++ benchmark/scripts/stop_workers.sh | 21 +++++++++ .../privacy/dp/DPBuiltinDMLTest.java | 44 ++++++++++++++----- 10 files changed, 219 insertions(+), 12 deletions(-) diff --git a/benchmark/scripts/collect_results.py b/benchmark/scripts/collect_results.py index c15ce63aa84..91f8d7bece6 100644 --- a/benchmark/scripts/collect_results.py +++ b/benchmark/scripts/collect_results.py @@ -1,3 +1,24 @@ +#------------------------------------------------------------- +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +#------------------------------------------------------------- + """ Parse per-run accuracy files into a single results.csv. diff --git a/benchmark/scripts/eval.dml b/benchmark/scripts/eval.dml index 7f0c89fbaa4..a9ad9e179c0 100644 --- a/benchmark/scripts/eval.dml +++ b/benchmark/scripts/eval.dml @@ -1,3 +1,24 @@ +#------------------------------------------------------------- +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +#------------------------------------------------------------- + # eval.dml — compute binary classification accuracy on held-out test set. # Arguments: data_dir, model_path, out_acc data_dir = $data_dir; diff --git a/benchmark/scripts/fedavg_dp.dml b/benchmark/scripts/fedavg_dp.dml index d4722fdb2a6..1715f44758e 100644 --- a/benchmark/scripts/fedavg_dp.dml +++ b/benchmark/scripts/fedavg_dp.dml @@ -1,3 +1,24 @@ +#------------------------------------------------------------- +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +#------------------------------------------------------------- + # ── fedavg_dp.dml ──────────────────────────────────────────────────────────── # Arguments (passed via -nvargs): # data_dir : path to benchmark/data/ diff --git a/benchmark/scripts/plot.py b/benchmark/scripts/plot.py index ac0a82850e3..71ab48d0e5c 100644 --- a/benchmark/scripts/plot.py +++ b/benchmark/scripts/plot.py @@ -1,3 +1,23 @@ +#------------------------------------------------------------- +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +#------------------------------------------------------------- """ Read results.csv and produce two figures: diff --git a/benchmark/scripts/prepare_data.py b/benchmark/scripts/prepare_data.py index dce8e8e8006..d2764322679 100644 --- a/benchmark/scripts/prepare_data.py +++ b/benchmark/scripts/prepare_data.py @@ -1,3 +1,23 @@ +#------------------------------------------------------------- +# +# 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. +# +#------------------------------------------------------------- """ Download the UCI Adult dataset, binarise labels, standardise features, split into 4 equal horizontal partitions for federated workers, and write diff --git a/benchmark/scripts/run_benchmark.sh b/benchmark/scripts/run_benchmark.sh index 70966fa30ad..52226c43f28 100755 --- a/benchmark/scripts/run_benchmark.sh +++ b/benchmark/scripts/run_benchmark.sh @@ -1,3 +1,24 @@ +#------------------------------------------------------------- +# +# 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. +# +#------------------------------------------------------------- + # 1. Prepare data (once). python benchmark/scripts/prepare_data.py diff --git a/benchmark/scripts/run_sweep.sh b/benchmark/scripts/run_sweep.sh index 4696ea5240a..9c9ff3d9b60 100755 --- a/benchmark/scripts/run_sweep.sh +++ b/benchmark/scripts/run_sweep.sh @@ -1,4 +1,25 @@ #!/usr/bin/env bash +#------------------------------------------------------------- +# +# 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. +# +#------------------------------------------------------------- + # Runs FedAvg for each epsilon value and the non-private baseline, # then evaluates accuracy. Results are appended to results/results.csv. set -euo pipefail diff --git a/benchmark/scripts/start_workers.sh b/benchmark/scripts/start_workers.sh index 23b5634230c..d41e7a73944 100755 --- a/benchmark/scripts/start_workers.sh +++ b/benchmark/scripts/start_workers.sh @@ -1,4 +1,25 @@ #!/usr/bin/env bash +#------------------------------------------------------------- +# +# 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. +# +#------------------------------------------------------------- + # Start 4 local SystemDS federated workers on ports 8301-8304. # Each worker is given the absolute path to its data shard directory. set -e diff --git a/benchmark/scripts/stop_workers.sh b/benchmark/scripts/stop_workers.sh index d1ff3cfde41..bccd9a18486 100755 --- a/benchmark/scripts/stop_workers.sh +++ b/benchmark/scripts/stop_workers.sh @@ -1,4 +1,25 @@ #!/usr/bin/env bash +#------------------------------------------------------------- +# +# 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. +# +#------------------------------------------------------------- + LOG_DIR="$(cd "$(dirname "$0")/../../benchmark/results" && pwd)" for i in 1 2 3 4; do PID_FILE="$LOG_DIR/worker${i}.pid" 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 index 16dd952adf7..9dbc8c71be3 100644 --- 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 @@ -1,15 +1,22 @@ -// ========================================================================== -// 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). -// +/* + * 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.functions.privacy.dp; @@ -30,6 +37,19 @@ import org.apache.sysds.test.TestUtils; import org.junit.Test; +/* + * ========================================================================== + * 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). + */ public class DPBuiltinDMLTest extends AutomatedTestBase { private static final String TEST_DIR = "functions/privacy/dp/"; From 520b432b714d83fb93ee0818219c46af99cb6437 Mon Sep 17 00:00:00 2001 From: Maya Anderson Date: Wed, 22 Jul 2026 11:54:31 +0200 Subject: [PATCH 24/43] More checkstyle fixes --- .../java/org/apache/sysds/common/Types.java | 8 +++--- .../sysds/hops/ParameterizedBuiltinOp.java | 27 +++++++++---------- .../parser/BuiltinFunctionExpression.java | 15 +++++------ .../cp/DPBuiltinCPInstructionTest.java | 3 ++- .../privacy/dp/DPBuiltinDMLTest.java | 1 - 5 files changed, 25 insertions(+), 29 deletions(-) diff --git a/src/main/java/org/apache/sysds/common/Types.java b/src/main/java/org/apache/sysds/common/Types.java index 611d5011fbb..bec3a339f0f 100644 --- a/src/main/java/org/apache/sysds/common/Types.java +++ b/src/main/java/org/apache/sysds/common/Types.java @@ -806,11 +806,9 @@ public static ReOrgOp valueOfByOpcode(String opcode) { /** Parameterized operations that require named variable arguments */ public enum ParamBuiltinOp { - 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 + 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 } /** Deep Neural Network specific operations */ diff --git a/src/main/java/org/apache/sysds/hops/ParameterizedBuiltinOp.java b/src/main/java/org/apache/sysds/hops/ParameterizedBuiltinOp.java index 3997d3402c8..7507eeb43ca 100644 --- a/src/main/java/org/apache/sysds/hops/ParameterizedBuiltinOp.java +++ b/src/main/java/org/apache/sysds/hops/ParameterizedBuiltinOp.java @@ -196,10 +196,10 @@ public Lop constructLops() case LIST: case AUTODIFF: case DP_LAPLACE: - case DP_GAUSSIAN:{ - ParameterizedBuiltin pbilop = new ParameterizedBuiltin( - inputlops, _op, getDataType(), getValueType(), et); - if( isMultiThreadedOpType() ) + case DP_GAUSSIAN: { + ParameterizedBuiltin pbilop = new ParameterizedBuiltin(inputlops, _op, getDataType(), getValueType(), + et); + if(isMultiThreadedOpType()) pbilop.setNumThreads(OptimizerUtils.getConstrainedNumThreads(_maxNumThreads)); setOutputDimensions(pbilop); setLineNumbers(pbilop); @@ -690,13 +690,13 @@ 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() ) { + 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) ) + 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) ) + else if("identity".equals(queryVal)) ret = new MatrixCharacteristics(dc.getRows(), dc.getCols(), -1, dc.getLength()); } } @@ -767,11 +767,10 @@ && getTargetHop().areDimsBelowThreshold() ) { // 2. For paramserv function, always be CP mode so that // the parameter server could have a central instruction // to determine the local or remote workers - if (_op == ParamBuiltinOp.TRANSFORMCOLMAP || _op == ParamBuiltinOp.TRANSFORMMETA - || _op == ParamBuiltinOp.TOSTRING || _op == ParamBuiltinOp.LIST - || _op == ParamBuiltinOp.CDF || _op == ParamBuiltinOp.INVCDF - || _op == ParamBuiltinOp.PARAMSERV - || _op == ParamBuiltinOp.DP_LAPLACE || _op == ParamBuiltinOp.DP_GAUSSIAN) { + if(_op == ParamBuiltinOp.TRANSFORMCOLMAP || _op == ParamBuiltinOp.TRANSFORMMETA || + _op == ParamBuiltinOp.TOSTRING || _op == ParamBuiltinOp.LIST || _op == ParamBuiltinOp.CDF || + _op == ParamBuiltinOp.INVCDF || _op == ParamBuiltinOp.PARAMSERV || _op == ParamBuiltinOp.DP_LAPLACE || + _op == ParamBuiltinOp.DP_GAUSSIAN) { _etype = ExecType.CP; } diff --git a/src/main/java/org/apache/sysds/parser/BuiltinFunctionExpression.java b/src/main/java/org/apache/sysds/parser/BuiltinFunctionExpression.java index 1c2c6ba8110..dd38d633d76 100644 --- a/src/main/java/org/apache/sysds/parser/BuiltinFunctionExpression.java +++ b/src/main/java/org/apache/sysds/parser/BuiltinFunctionExpression.java @@ -2165,12 +2165,11 @@ 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. + * 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)) + if(!(queryExpr instanceof StringIdentifier)) raiseValidateError(getOpCode() + ": 'query' must be a string literal", false, LanguageErrorCodes.INVALID_PARAMETERS); return ((StringIdentifier) queryExpr).getValue(); @@ -2178,16 +2177,16 @@ private String getDPQueryLiteral(Expression queryExpr) { /** 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) { + 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); + raiseValidateError( + getOpCode() + ": unknown query type '" + query + "' (expected colMeans, colSums, or identity)", + false, LanguageErrorCodes.INVALID_PARAMETERS); return null; // unreachable } } 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 index 0345ea73fc2..88bcd30b634 100755 --- a/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java +++ b/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java @@ -264,7 +264,8 @@ public void testMixedCompositionExceedsEitherAlone() { lapOnly.compose(0.5, 0.0, 1.0); gauOnly.compose(0.5, 1e-5, 1.0); - Assert.assertTrue("Mixed cost must exceed Laplace-only cost", mixed.totalEpsilonSpent() > lapOnly.totalEpsilonSpent()); + Assert.assertTrue("Mixed cost must exceed Laplace-only cost", + mixed.totalEpsilonSpent() > lapOnly.totalEpsilonSpent()); Assert.assertTrue("Mixed cost must exceed Gaussian-only cost", mixed.totalEpsilonSpent() > gauOnly.totalEpsilonSpent()); } 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 index 9dbc8c71be3..92bd527afbe 100644 --- 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 @@ -17,7 +17,6 @@ * under the License. */ - package org.apache.sysds.test.functions.privacy.dp; import static org.junit.Assert.assertEquals; From e5651ff3e7c6316eea023c291b4e8c71af7162dd Mon Sep 17 00:00:00 2001 From: Maya Anderson Date: Wed, 22 Jul 2026 12:33:56 +0200 Subject: [PATCH 25/43] Add missing break. Fixes test --- .../org/apache/sysds/parser/BuiltinFunctionExpression.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/apache/sysds/parser/BuiltinFunctionExpression.java b/src/main/java/org/apache/sysds/parser/BuiltinFunctionExpression.java index dd38d633d76..593b0f5d615 100644 --- a/src/main/java/org/apache/sysds/parser/BuiltinFunctionExpression.java +++ b/src/main/java/org/apache/sysds/parser/BuiltinFunctionExpression.java @@ -2003,8 +2003,9 @@ else if(this.getOpCode() == Builtins.MAX_POOL || this.getOpCode() == Builtins.AV output.setBlocksize (id.getBlocksize()); output.setValueType(id.getValueType()); } - else + else raiseValidateError("Local instruction not allowed in dml script"); + break; case DP_LAPLACE: { checkNumParameters(4); checkMatrixParam(getFirstExpr()); From a328bd75a7b7eb36668f973b601aa9791e0604d1 Mon Sep 17 00:00:00 2001 From: Maya Anderson Date: Tue, 28 Jul 2026 12:32:29 +0200 Subject: [PATCH 26/43] Change ParameterizedBuiltinOp.compare() so that two syntactically identical dp_laplace or dp_gaussian calls are never be merged into one execution. --- .../org/apache/sysds/hops/ParameterizedBuiltinOp.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/apache/sysds/hops/ParameterizedBuiltinOp.java b/src/main/java/org/apache/sysds/hops/ParameterizedBuiltinOp.java index 7507eeb43ca..393d1244c0a 100644 --- a/src/main/java/org/apache/sysds/hops/ParameterizedBuiltinOp.java +++ b/src/main/java/org/apache/sysds/hops/ParameterizedBuiltinOp.java @@ -952,8 +952,14 @@ public boolean compare( Hop that ) { if( !(that instanceof ParameterizedBuiltinOp) ) return false; - - ParameterizedBuiltinOp that2 = (ParameterizedBuiltinOp)that; + + // NOTE: dp_laplace/dp_gaussian draw fresh random noise on every call and record a + // privacy-budget charge as a side effect (see DPBuiltinCPInstruction), so two + // syntactically identical calls must never be merged into one execution. + if( _op == ParamBuiltinOp.DP_LAPLACE || _op == ParamBuiltinOp.DP_GAUSSIAN ) + return false; + + ParameterizedBuiltinOp that2 = (ParameterizedBuiltinOp)that; boolean ret = (_op == that2._op && _paramIndexMap!=null && that2._paramIndexMap!=null && _paramIndexMap.size() == that2._paramIndexMap.size() From 1a433911c6eb5c43694fcdb52dcaefa109ca4e11 Mon Sep 17 00:00:00 2001 From: Maya Anderson Date: Tue, 28 Jul 2026 18:49:25 +0200 Subject: [PATCH 27/43] Address David's review comments: unite utility methods, reorder alphabetically ParamBuiltinOp --- .../org/apache/sysds/common/Builtins.java | 6 +++--- .../java/org/apache/sysds/common/Types.java | 7 ++++++- .../parser/BuiltinFunctionExpression.java | 20 +++++++------------ 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/src/main/java/org/apache/sysds/common/Builtins.java b/src/main/java/org/apache/sysds/common/Builtins.java index 6dce5fb30eb..e7e181984e9 100644 --- a/src/main/java/org/apache/sysds/common/Builtins.java +++ b/src/main/java/org/apache/sysds/common/Builtins.java @@ -116,9 +116,6 @@ 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), @@ -130,6 +127,9 @@ public enum Builtins { SETDIFF("setdiff", true), DIST("dist", true), DMV("dmv", true), + DP_GAUSSIAN("dp_gaussian", false), + DP_LAPLACE("dp_laplace", false), + DP_SET_BUDGET("dp_set_budget", false), DROP_INVALID_TYPE("dropInvalidType", false), DROP_INVALID_LENGTH("dropInvalidLength", false), EIGEN("eigen", false, ReturnType.MULTI_RETURN), diff --git a/src/main/java/org/apache/sysds/common/Types.java b/src/main/java/org/apache/sysds/common/Types.java index bec3a339f0f..8c42d5c596d 100644 --- a/src/main/java/org/apache/sysds/common/Types.java +++ b/src/main/java/org/apache/sysds/common/Types.java @@ -806,7 +806,12 @@ public static ReOrgOp valueOfByOpcode(String opcode) { /** Parameterized operations that require named variable arguments */ public enum ParamBuiltinOp { - AUTODIFF, CDF, CONTAINS, DP_LAPLACE, DP_GAUSSIAN, INVALID, INVCDF, GROUPEDAGG, RMEMPTY, REPLACE, REXPAND, + AUTODIFF, CDF, CONTAINS, + // DP_LAPLACE/DP_GAUSSIAN reuse this Hop/Lop family (ParameterizedBuiltinOp, ParameterizedBuiltin Lop) + // but route to a distinct CPType.DPBuiltin/DPBuiltinCPInstruction at the CP-instruction layer instead + // of ParameterizedBuiltinCPInstruction (see Opcodes.DP_LAPLACE / Opcodes.DP_GAUSSIAN). + 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/parser/BuiltinFunctionExpression.java b/src/main/java/org/apache/sysds/parser/BuiltinFunctionExpression.java index 593b0f5d615..1fdfc08884e 100644 --- a/src/main/java/org/apache/sysds/parser/BuiltinFunctionExpression.java +++ b/src/main/java/org/apache/sysds/parser/BuiltinFunctionExpression.java @@ -2013,8 +2013,7 @@ else if(this.getOpCode() == Builtins.MAX_POOL || this.getOpCode() == Builtins.AV checkValueTypeParam(getSecondExpr(), ValueType.STRING); checkScalarParam(getThirdExpr()); checkScalarParam(getFourthExpr()); - String dpLaplaceQuery = getDPQueryLiteral(getSecondExpr()); - long[] dpLaplaceDims = getDPOutputDims(dpLaplaceQuery, + long[] dpLaplaceDims = getDPOutputDims(getSecondExpr(), getFirstExpr().getOutput().getDim1(), getFirstExpr().getOutput().getDim2()); output.setDataType(DataType.MATRIX); output.setValueType(ValueType.FP64); @@ -2029,8 +2028,7 @@ else if(this.getOpCode() == Builtins.MAX_POOL || this.getOpCode() == Builtins.AV checkScalarParam(getThirdExpr()); checkScalarParam(getFourthExpr()); checkScalarParam(getFifthExpr()); - String dpGaussianQuery = getDPQueryLiteral(getSecondExpr()); - long[] dpGaussianDims = getDPOutputDims(dpGaussianQuery, + long[] dpGaussianDims = getDPOutputDims(getSecondExpr(), getFirstExpr().getOutput().getDim1(), getFirstExpr().getOutput().getDim2()); output.setDataType(DataType.MATRIX); output.setValueType(ValueType.FP64); @@ -2165,19 +2163,15 @@ 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) { + /** Output dimensions of T %*% X for the given named query, X being n x d. */ + private long[] getDPOutputDims(Expression queryExpr, long n, long d) { + // 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. if(!(queryExpr instanceof StringIdentifier)) raiseValidateError(getOpCode() + ": 'query' must be a string literal", false, LanguageErrorCodes.INVALID_PARAMETERS); - return ((StringIdentifier) queryExpr).getValue(); - } + String query = ((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": From da825c701f27a14974efea4db0cee0c2765d82b0 Mon Sep 17 00:00:00 2001 From: Maya Anderson Date: Tue, 28 Jul 2026 19:17:02 +0200 Subject: [PATCH 28/43] Route dp_laplace/dp_gaussian through the named-parameter builtin path Flip Builtins.DP_LAPLACE/DP_GAUSSIAN to parameterized=true so the parser builds a ParameterizedBuiltinFunctionExpression for these calls instead of a positional BuiltinFunctionExpression. This lets them reuse the existing varParams-based parsing, instead of hand-unpacking expr/expr2/expr3 by position in DMLTranslator and re-deriving parameter names from argument order in BuiltinFunctionExpression. Laplace and Gaussian validation is merged into one validateDpMechanism(), varying only on whether 'delta' is required, since the two mechanisms differ by exactly that one optional parameter. --- .../org/apache/sysds/common/Builtins.java | 4 +- .../parser/BuiltinFunctionExpression.java | 52 ------------------ .../apache/sysds/parser/DMLTranslator.java | 30 +---------- ...arameterizedBuiltinFunctionExpression.java | 53 +++++++++++++++++++ 4 files changed, 57 insertions(+), 82 deletions(-) diff --git a/src/main/java/org/apache/sysds/common/Builtins.java b/src/main/java/org/apache/sysds/common/Builtins.java index e7e181984e9..6c3971f499f 100644 --- a/src/main/java/org/apache/sysds/common/Builtins.java +++ b/src/main/java/org/apache/sysds/common/Builtins.java @@ -127,8 +127,8 @@ public enum Builtins { SETDIFF("setdiff", true), DIST("dist", true), DMV("dmv", true), - DP_GAUSSIAN("dp_gaussian", false), - DP_LAPLACE("dp_laplace", false), + DP_GAUSSIAN("dp_gaussian", false, true), + DP_LAPLACE("dp_laplace", false, true), DP_SET_BUDGET("dp_set_budget", false), DROP_INVALID_TYPE("dropInvalidType", false), DROP_INVALID_LENGTH("dropInvalidLength", false), diff --git a/src/main/java/org/apache/sysds/parser/BuiltinFunctionExpression.java b/src/main/java/org/apache/sysds/parser/BuiltinFunctionExpression.java index 1fdfc08884e..f14210d9b7c 100644 --- a/src/main/java/org/apache/sysds/parser/BuiltinFunctionExpression.java +++ b/src/main/java/org/apache/sysds/parser/BuiltinFunctionExpression.java @@ -2006,35 +2006,6 @@ else if(this.getOpCode() == Builtins.MAX_POOL || this.getOpCode() == Builtins.AV else raiseValidateError("Local instruction not allowed in dml script"); break; - case DP_LAPLACE: { - checkNumParameters(4); - checkMatrixParam(getFirstExpr()); - checkScalarParam(getSecondExpr()); - checkValueTypeParam(getSecondExpr(), ValueType.STRING); - checkScalarParam(getThirdExpr()); - checkScalarParam(getFourthExpr()); - long[] dpLaplaceDims = getDPOutputDims(getSecondExpr(), - 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()); - long[] dpGaussianDims = getDPOutputDims(getSecondExpr(), - 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()); @@ -2163,29 +2134,6 @@ else if(this.getOpCode() == Builtins.MAX_POOL || this.getOpCode() == Builtins.AV } } - /** Output dimensions of T %*% X for the given named query, X being n x d. */ - private long[] getDPOutputDims(Expression queryExpr, long n, long d) { - // 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. - if(!(queryExpr instanceof StringIdentifier)) - raiseValidateError(getOpCode() + ": 'query' must be a string literal", false, - LanguageErrorCodes.INVALID_PARAMETERS); - String query = ((StringIdentifier) queryExpr).getValue(); - - 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/DMLTranslator.java b/src/main/java/org/apache/sysds/parser/DMLTranslator.java index a9d5279c26b..28e5fc9bb46 100644 --- a/src/main/java/org/apache/sysds/parser/DMLTranslator.java +++ b/src/main/java/org/apache/sysds/parser/DMLTranslator.java @@ -2014,6 +2014,8 @@ private Hop processParameterizedBuiltinFunctionExpression(ParameterizedBuiltinFu case TRANSFORMMETA: case PARAMSERV: case AUTODIFF: + case DP_LAPLACE: + case DP_GAUSSIAN: currBuiltinOp = new ParameterizedBuiltinOp(target.getName(), target.getDataType(), target.getValueType(), ParamBuiltinOp.valueOf(source.getOpCode().name()), paramHops); break; @@ -2589,34 +2591,6 @@ 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 diff --git a/src/main/java/org/apache/sysds/parser/ParameterizedBuiltinFunctionExpression.java b/src/main/java/org/apache/sysds/parser/ParameterizedBuiltinFunctionExpression.java index 314440628e0..f6f9ad4a268 100644 --- a/src/main/java/org/apache/sysds/parser/ParameterizedBuiltinFunctionExpression.java +++ b/src/main/java/org/apache/sysds/parser/ParameterizedBuiltinFunctionExpression.java @@ -268,6 +268,11 @@ public void validateExpression(HashMap ids, HashMap varParams = getVarParams(); + if( varParams.containsKey(null) ) + varParams.put("target", varParams.remove(null)); + + boolean gaussian = getOpCode() == Builtins.DP_GAUSSIAN; + Set valid = gaussian ? + CollectionUtils.asSet("target", "query", "sensitivity", "epsilon", "delta") : + CollectionUtils.asSet("target", "query", "sensitivity", "epsilon"); + checkInvalidParameters(getOpCode(), varParams, valid); + + Expression target = getVarParam("target"); + checkTargetParam(target, conditional); + for( String param : valid ) + if( !param.equals("target") ) + checkScalarParam(getOpCode().getName(), param, conditional); + + long[] dims = getDPOutputDims(getVarParam("query"), + target.getOutput().getDim1(), target.getOutput().getDim2()); + output.setDataType(DataType.MATRIX); + output.setValueType(ValueType.FP64); + output.setDimensions(dims[0], dims[1]); + } + + /** Output dimensions of T %*% X for the given named query, X being n x d. */ + private long[] getDPOutputDims(Expression queryExpr, long n, long d) { + // 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. + if(!(queryExpr instanceof StringIdentifier)) + raiseValidateError(getOpCode() + ": 'query' must be a string literal", false, + LanguageErrorCodes.INVALID_PARAMETERS); + String query = ((StringIdentifier) queryExpr).getValue(); + + 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 checkScalarParam(String group, String param, boolean conditional) { Expression eparam = getVarParam(param); if( eparam==null ) { From ef27332f7695250294e833863fe8ae0dbff99abe Mon Sep 17 00:00:00 2001 From: Maya Anderson Date: Wed, 29 Jul 2026 20:38:08 +0200 Subject: [PATCH 29/43] Code review comments: remove special characters --- benchmark/scripts/collect_results.py | 4 +- benchmark/scripts/plot.py | 22 ++-- benchmark/scripts/prepare_data.py | 2 +- .../org/apache/sysds/parser/DMLProgram.java | 10 +- .../apache/sysds/parser/DMLTranslator.java | 2 +- .../context/ExecutionContext.java | 4 +- .../cp/DPBuiltinCPInstruction.java | 92 ++++++++-------- .../privacy/dp/DPBudgetAccountant.java | 104 +++++++++--------- .../cp/DPBuiltinCPInstructionTest.java | 45 ++++---- .../privacy/dp/DPBuiltinDMLTest.java | 8 +- 10 files changed, 152 insertions(+), 141 deletions(-) diff --git a/benchmark/scripts/collect_results.py b/benchmark/scripts/collect_results.py index 91f8d7bece6..b874876d871 100644 --- a/benchmark/scripts/collect_results.py +++ b/benchmark/scripts/collect_results.py @@ -45,10 +45,10 @@ def parse_acc(path: pathlib.Path) -> float: 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, + rows.append(dict(label=f"epsilon={eps}", epsilon=eps, private=1, accuracy=parse_acc(p))) else: - print(f"Warning: {p} not found — skipping") + print(f"Warning: {p} not found - skipping") out = RESULTS / "results.csv" with open(out, "w", newline="") as f: diff --git a/benchmark/scripts/plot.py b/benchmark/scripts/plot.py index 71ab48d0e5c..7b160d065de 100644 --- a/benchmark/scripts/plot.py +++ b/benchmark/scripts/plot.py @@ -22,7 +22,7 @@ Read results.csv and produce two figures: 1. accuracy_vs_epsilon.png - Line plot: x = ε, y = accuracy. + Line plot: x = epsilon, y = accuracy. Horizontal dashed line = non-private baseline. Points labelled with accuracy values. @@ -57,7 +57,7 @@ acc_vals = [r["accuracy"] for r in dp_rows] baseline_acc = baseline["accuracy"] -# ── Figure 1: Accuracy vs ε ─────────────────────────────────────────────── +# ── Figure 1: Accuracy vs epsilon ─────────────────────────────────────────────── fig, ax = plt.subplots(figsize=(7, 4.5)) ax.plot(eps_vals, acc_vals, marker="o", linewidth=2, @@ -74,9 +74,9 @@ 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_xlabel("Privacy budget epsilon (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)", +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)) @@ -93,9 +93,9 @@ 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 +# Position bars at their true epsilon value on a log-scaled x-axis (rather than +# evenly-spaced categorical slots) so the visual spacing between 0.5 to 1 and +# 4 to 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, @@ -112,9 +112,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_xlabel("Privacy budget epsilon", fontsize=11) ax.set_ylabel("Accuracy drop vs. baseline", fontsize=11) -ax.set_title("Utility Cost of Differential Privacy — DP-FedAvg on Adult", +ax.set_title("Utility Cost of Differential Privacy - DP-FedAvg on Adult", fontsize=11) ax.grid(True, axis="y", linestyle=":", alpha=0.5) @@ -126,9 +126,9 @@ # ── Console summary table ───────────────────────────────────────────────── print() -print(f"{'ε':>8} {'accuracy':>10} {'drop':>8}") +print(f"{'epsilon':>8} {'accuracy':>10} {'drop':>8}") print("-" * 34) -print(f"{'baseline':>8} {baseline_acc:10.4f} {'—':>8}") +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 index d2764322679..1a18e067bc7 100644 --- a/benchmark/scripts/prepare_data.py +++ b/benchmark/scripts/prepare_data.py @@ -67,7 +67,7 @@ def download(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 + # 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] diff --git a/src/main/java/org/apache/sysds/parser/DMLProgram.java b/src/main/java/org/apache/sysds/parser/DMLProgram.java index bc2278e26f6..eed9f802aee 100644 --- a/src/main/java/org/apache/sysds/parser/DMLProgram.java +++ b/src/main/java/org/apache/sysds/parser/DMLProgram.java @@ -38,11 +38,11 @@ public class DMLProgram 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 + * Session-wide differential privacy budget resolved at compile time from a dp_set_budget(epsilon, delta) + * call (its arguments must be numeric literals, see BuiltinFunctionExpression). Null until such a call is + * encountered during HOP construction; consulted by ExecutionContext#getDPBudgetAccountant() in place of + * its hardcoded default. This is deliberately a plain field (not a Hop/Lop/Instruction): since Program + * holds a reference back to this DMLProgram (see Program#getDMLProg()), the value survives from * compile time through to runtime without needing a runtime instruction at all. */ private Double _dpBudgetEpsilon; diff --git a/src/main/java/org/apache/sysds/parser/DMLTranslator.java b/src/main/java/org/apache/sysds/parser/DMLTranslator.java index 28e5fc9bb46..07d52e0af47 100644 --- a/src/main/java/org/apache/sysds/parser/DMLTranslator.java +++ b/src/main/java/org/apache/sysds/parser/DMLTranslator.java @@ -2595,7 +2595,7 @@ else if ( sop.equalsIgnoreCase(Opcodes.NOTEQUAL.toString()) ) // 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 + // 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. 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 8fc88f3b375..0055b9b521d 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 @@ -150,8 +150,8 @@ public void setLineage(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 + * dp_set_budget(epsilon, delta) with compile-time literal arguments, that value (resolved onto the + * DMLProgram during HOP construction - see DMLTranslator's DP_SET_BUDGET case) is used * instead of the hardcoded defaults. */ public DPBudgetAccountant getDPBudgetAccountant() { 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 index 3904dff1c5a..7522be5a8d3 100755 --- a/src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinCPInstruction.java +++ b/src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinCPInstruction.java @@ -33,24 +33,25 @@ /** * 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) + * 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 + * The instruction receives the original n x d} matrix X, builds a transformation matrix T + * (k x n) from the named query (see {@link #buildTransform}), and returns a noisy release of + * T %*% X. The noise is not added as a separate elementwise pass over a materialised aggregate: it is injected + * by augmenting T with an identity block and 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 + * Sensitivity norm: sensitivity is not interchangeable between the two builtins. dp_laplace calibrates + * its noise scale to the L1 sensitivity of T %*% X to a single-record change; dp_gaussian calibrates + * its stdev to the L2 sensitivity. For a scalar release (e.g. query="colMeans" on single-column 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 + * analysis that derives sensitivity from T's column norms and a declared per-record bound on X; every * other line in this class would stay unchanged. */ public class DPBuiltinCPInstruction extends ComputationCPInstruction { @@ -91,7 +92,7 @@ private DPBuiltinCPInstruction(CPOperand input, CPOperand output, String opcode, // ----------------------------------------------------------------------- /** - * Reconstructs a {@code DPBuiltinCPInstruction} from its serialised instruction string produced by the LOP layer. + * Reconstructs a 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 @@ -138,16 +139,17 @@ public static DPBuiltinCPInstruction parseInstruction(String str) { /** * 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. + * - Read the original {@link MatrixBlock} X from the variable table. + * - Build the transformation matrix T (k x n) from query (see {@link #buildTransform}). + * - Determine sensitivity via {@link #sensitivityOf}. + * - Generate a noise {@link MatrixBlock} shaped k x d. + * - Fuse 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 + * Fusion derivation: for T (k x n), X (n x d) and noise N (k x d), + * let T' = [T | I_k] (k x (n+k)) and X' = [X ; N] ((n+k) x d). Then + * 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 @@ -194,16 +196,16 @@ public void processInstruction(ExecutionContext ec) { // ----------------------------------------------------------------------- /** - * 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}. + * Builds the k x n transformation matrix T for the given named query, to be left-multiplied against + * the n x d input X as 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. + * - "colMeans": T is 1 x n, filled with 1/n - T %*% X is the column-mean row vector. + * - "colSums": T is 1 x n, filled with 1.0 - T %*% X is the column-sum row vector. + * - "identity": T is the n x n identity (built sparsely via {@link #identity}) - T %*% X is 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. + * Row-wise aggregates (rowMeans/rowSums) reduce across the feature axis of X, i.e. they are + * naturally X %*% T' (right-multiply), not T %*% X, so they are intentionally not supported here. */ private static MatrixBlock buildTransform(String query, int n) { switch(query) { @@ -233,10 +235,10 @@ private static MatrixBlock buildTransform(String query, int n) { } /** - * 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}. + * Builds a k x k identity matrix, sparsely, by reusing the existing {@link LibMatrixReorg#diag} reorg + * operator (the same runtime path DML's diag() builtin uses to expand a vector into a diagonal matrix). + * Keeps memory O(k) rather than O(k^2), which matters for the query="identity" case where + * k equals the number of rows of X. */ private static MatrixBlock identity(int k) { MatrixBlock ones = new MatrixBlock(k, 1, false); @@ -252,14 +254,14 @@ private static MatrixBlock identity(int k) { // ----------------------------------------------------------------------- /** - * 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 + * Returns the sensitivity of the release T %*% X to a single-record change, in the norm required by the + * mechanism actually invoked: L1 for dp_laplace, L2 for 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 + * 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. + * replace this body with an analysis that derives sensitivity from T's column norms and a declared + * per-record bound on 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 @@ -274,9 +276,9 @@ private double sensitivityOf(MatrixBlock T) { // ----------------------------------------------------------------------- /** - * 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}). + * Generates a rows x cols noise {@link MatrixBlock} - matching the shape of the release T %*% X - + * filled with samples from the mechanism-appropriate distribution calibrated to (sensitivity, + * epsilon, 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. @@ -305,9 +307,9 @@ private MatrixBlock generateNoise(int rows, int cols, double sensitivity, double } /** - * Fills {@code block} with i.i.d. Laplace(0, scale) samples using the inverse-CDF method. + * Fills 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|) + * For u in 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(); @@ -327,7 +329,7 @@ private static void fillLaplaceNoise(MatrixBlock block, double scale) { } /** - * Fills {@code block} with i.i.d. N(0, sigma²) samples. + * Fills block with i.i.d. N(0, sigma^2) samples. * * Uses {@link ThreadLocalRandom#nextGaussian()} which is thread-safe and does not require external libraries. */ @@ -347,7 +349,7 @@ private static void fillGaussianNoise(MatrixBlock block, double sigma) { // ----------------------------------------------------------------------- /** - * Parses a parameter value as a positive {@code double}. + * Parses a parameter value as a positive double. * * @throws DMLRuntimeException if the key is absent, unparseable, or non-positive */ 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 index b367cb7fcc3..c00ca317ab0 100644 --- a/src/main/java/org/apache/sysds/runtime/privacy/dp/DPBudgetAccountant.java +++ b/src/main/java/org/apache/sysds/runtime/privacy/dp/DPBudgetAccountant.java @@ -28,29 +28,33 @@ * 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}: + * The mechanism type (Laplace vs Gaussian) is inferred from the 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 + * - Laplace (delta == 0): pure epsilon-DP. The budget cost is tracked via basic composition: each release contributes + * exactly its epsilon 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 delta). Noise scale is + * calibrated to L1 sensitivity (see {@link #compose}). + * - Gaussian (delta > 0): (epsilon, delta)-DP via Renyi DP composition. + * Renyi divergences at a discrete set of orders alpha compose additively; the accumulated sum is converted to (epsilon, delta) 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 ε. + * When both mechanisms are used in the same script the total cost is: + * epsilon_total = epsilon_Laplace_sum + epsilon_Gaussian_RDP + * This follows from basic composition of a pure-DP mechanism with an approximate-DP mechanism, which is additive in epsilon. * - * 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. + * Renyi orders tracked (Gaussian path) alpha in {2, 4, 8, 16, 32, 64, 128, 256, 512, 1024}. At query time the minimum + * converted epsilon 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. + * Gaussian RDP divergence For the Gaussian mechanism with noise scale sigma and L2 sensitivity delta_f: + * D_alpha = alpha * delta_f^2 / (2*sigma^2) sigma + * is back-derived from the caller's (epsilon, delta) via the standard calibration formula (see {@link #gaussianSigma}). Note that + * sensitivity cancels in the final expression, so the RDP cost depends only on the (epsilon, delta) parameters. * - * RDP → (ε, δ) conversion (Mironov 2017, Proposition 3) ε(α) = R[α] + log(1 − 1/α) − log(δ·(α−1)) / α + * RDP => (epsilon, delta) conversion (Mironov 2017, Proposition 3): + * epsilon(alpha) = R[alpha] + log(1 − 1/alpha) − log(delta*(alpha−1)) / alpha * - * One instance is created per {@code ExecutionContext} (lazy init). It is garbage-collected with the context when the + * One instance is created per 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 @@ -61,7 +65,7 @@ public class DPBudgetAccountant { // ----------------------------------------------------------------------- - // Rényi orders used for Gaussian composition + // Renyi orders used for Gaussian composition // ----------------------------------------------------------------------- private static final double DEFAULT_EPSILON_BUDGET = 1.0; @@ -69,7 +73,7 @@ public class DPBudgetAccountant { 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 + * Discrete set of Renyi orders alpha. 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}; @@ -78,22 +82,22 @@ public class DPBudgetAccountant { // State // ----------------------------------------------------------------------- - /** Accumulated Rényi divergence at each order (Gaussian releases only). */ + /** Accumulated Renyi divergence at each order (Gaussian releases only). */ private final double[] _rdpSum = new double[ORDERS.length]; /** - * Running sum of pure ε from Laplace releases. + * Running sum of pure epsilon 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 + * Laplace gives pure epsilon-DP (no delta). Basic composition is exact and tighter than the RDP conversion path for Laplace + * (which would introduce an unnecessary delta and produce a looser bound). Each Laplace release adds its epsilon here; the * total is added directly in {@link #totalEpsilonSpent()}. */ private double _pureEpsilonSum = 0.0; - /** Total privacy budget (ε) for the script execution. */ + /** Total privacy budget (epsilon) for the script execution. */ private final double _epsilonBudget; - /** δ used for the Gaussian RDP-to-(ε,δ) conversion. */ + /** delta used for the Gaussian RDP-to-(epsilon,delta) conversion. */ private final double _delta; /** Number of releases recorded so far (for error messages). */ @@ -107,11 +111,11 @@ public class DPBudgetAccountant { * 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 + * 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)) + * @param epsilonBudget total epsilon budget for the script execution (must be > 0) + * @param delta delta used for the Gaussian RDP-to-(epsilon,delta) conversion (must be in (0,1)) */ public DPBudgetAccountant(double epsilonBudget, double delta) { if(!(epsilonBudget > 0)) @@ -123,7 +127,7 @@ public DPBudgetAccountant(double epsilonBudget, double delta) { } /** - * Convenience constructor using a liberal default δ = 1e-5. Suitable when the calling script does not specify δ + * Convenience constructor using a liberal default delta = 1e-5. Suitable when the calling script does not specify delta * explicitly. */ public DPBudgetAccountant(double epsilonBudget) { @@ -131,7 +135,7 @@ public DPBudgetAccountant(double epsilonBudget) { } /** - * Default constructor using defaults. Suitable when the calling script does not specify ε, δ explicitly. + * Default constructor using defaults. Suitable when the calling script does not specify epsilon, delta explicitly. */ public DPBudgetAccountant() { this(DEFAULT_EPSILON_BUDGET, DEFAULT_DELTA); @@ -147,27 +151,28 @@ public DPBudgetAccountant() { * 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 + * Mechanism selection (see class-level Javadoc for details): + * - delta == 0 => Laplace, pure epsilon-DP basic composition + * - delta > 0 => Gaussian, Renyi 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} + * @param epsilon per-release epsilon parameter (must be >= 0) + * @param delta per-release delta parameter (0 for Laplace, >= 0 for Gaussian) + * @param sensitivity sensitivity of the released quantity (must be > 0). The norm depends on the mechanism + * selected by delta: callers must supply the L1 sensitivity when + * delta == 0 (Laplace), and the L2 sensitivity when 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 + * @throws DMLRuntimeException if the cumulative epsilon 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. + // Laplace: pure epsilon-DP, basic composition - cost is exactly epsilon. _pureEpsilonSum += epsilon; } else { - // Gaussian: accumulate Rényi divergence at each order, then convert. + // Gaussian: accumulate Renyi 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); @@ -177,7 +182,7 @@ public void compose(double epsilon, double delta, double sensitivity) { double spentEpsilon = totalEpsilonSpent(); if(spentEpsilon > _epsilonBudget) { throw new DMLRuntimeException(String.format( - "Privacy budget exhausted after %d release(s): " + "spent ε ≈ %.6f exceeds budget ε = %.6f (δ = %.2e). " + "Privacy budget exhausted after %d release(s): " + "spent epsilon %.6f exceeds budget epsilon = %.6f (delta = %.2e). " + "Reduce the number of releases or widen the budget.", _releaseCount, spentEpsilon, _epsilonBudget, _delta)); } @@ -188,13 +193,13 @@ public void compose(double epsilon, double delta, double sensitivity) { // ----------------------------------------------------------------------- /** - * Returns the current total privacy cost as an ε value. + * Returns the current total privacy cost as an epsilon value. * - * Total = Laplace pure-ε sum + Gaussian RDP-converted ε (clamped to zero when no Gaussian releases have been + * Total = Laplace pure-epsilon sum + Gaussian RDP-converted epsilon (clamped to zero when no Gaussian releases have been * recorded). */ public double totalEpsilonSpent() { - // Take min_α(ε_α) as the current total privacy cost + // Take min_alpha(epsilon_alpha) as the current total privacy cost double gaussianEps = Double.MAX_VALUE; for(int i = 0; i < ORDERS.length; i++) { double alpha = ORDERS[i]; @@ -204,13 +209,13 @@ public double totalEpsilonSpent() { } // 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. + // so Laplace-only scripts are not penalised by delta they never requested. if(gaussianEps < 0) gaussianEps = 0.0; return _pureEpsilonSum + gaussianEps; } - /** Returns the remaining ε budget (negative if the budget is exceeded). */ + /** Returns the remaining epsilon budget (negative if the budget is exceeded). */ public double remainingBudget() { return _epsilonBudget - totalEpsilonSpent(); } @@ -225,17 +230,18 @@ public int releaseCount() { // ----------------------------------------------------------------------- /** - * Rényi divergence of order α for the Gaussian mechanism (Mironov 2017, Proposition 3, example 2): D_α = α · Δf² / - * (2σ²) + * Renyi divergence of order alpha for the Gaussian mechanism (Mironov 2017, Proposition 3, example 2): + * D_alpha = alpha * delta_f^2 / (2 * sigma^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. + * Gaussian noise scale sigma calibrated to (epsilon, delta)-DP: + * sigma = delta_f * sqrt(2 * log(1.25 / delta)) / epsilon + * 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/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java b/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java index 88bcd30b634..5723dde54dc 100755 --- a/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java +++ b/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java @@ -30,12 +30,15 @@ import org.junit.Assert; /** - * Tests for {@code DPBuiltinCPInstruction} and {@code DPBudgetAccountant}. + * Tests for DPBuiltinCPInstruction and 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 + * 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 @@ -53,7 +56,7 @@ public class DPBuiltinCPInstructionTest { 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), + // (conversion formula gives -inf when rdpSum = 0 for all orders), // so remainingBudget() should exceed the budget. Assert.assertTrue("No releases should leave budget intact", acc.remainingBudget() > 0); Assert.assertEquals(0, acc.releaseCount()); @@ -100,10 +103,10 @@ public void testCompositionIsMonotonicallyIncreasing() { @Test public void testGaussianTighterThanLaplaceForSameEpsilon() { - // For the same nominal (ε, δ), Gaussian uses RDP composition which + // For the same nominal (epsilon, delta), Gaussian uses RDP composition which // is tighter than Laplace with basic composition. After 5 releases: - // Laplace (basic, worst-case): 5ε - // Gaussian (RDP) : something < 5ε + // Laplace (basic, worst-case): 5epsilon + // Gaussian (RDP) : something < 5epsilon double eps = 0.5; double delta = 1e-5; @@ -135,8 +138,8 @@ public void testRemainingBudgetDecreasesMonotonically() { @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 + // For Laplace, the accountant uses basic (pure epsilon-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); @@ -290,22 +293,22 @@ public void testReleaseCountTracksAllReleases() { @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 + // For the Gaussian mechanism: sigma = delta_f*sqrt(2*ln(1.25/delta))/epsilon, so + // D_alpha = alpha*delta_f^2/(2*sigma^2) = alpha*epsilon^2/(4*ln(1.25/delta)). + // Sensitivity cancels. Two accountants with the same (epsilon,delta) 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 (ε,δ) - Assert.assertEquals("Gaussian RDP cost must be independent of sensitivity when (ε,δ) are fixed", + acc2.compose(0.5, 1e-5, 100.0); // sensitivity = 100, same (epsilon,delta) + Assert.assertEquals("Gaussian RDP cost must be independent of sensitivity when (epsilon,delta) 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 ε. + // D_alpha is proportional to epsilon^2, so a release declared at a higher epsilon (less noise, more + // privacy loss) must cost more budget than one at a lower epsilon. DPBudgetAccountant lowEps = new DPBudgetAccountant(100.0, 1e-5); DPBudgetAccountant highEps = new DPBudgetAccountant(100.0, 1e-5); lowEps.compose(0.1, 1e-5, 1.0); @@ -324,7 +327,7 @@ public void testGaussianLargerEpsilonCostsMoreBudget() { @Test public void testLaplaceNoiseMeanNearZero() { - // For 10000 samples the empirical mean should be within 3σ/√n of 0. + // For 10000 samples the empirical mean should be within 3*sigma/sqrt(n) of 0. int n = 10_000; double scale = 2.0; double[] samples = sampleLaplace(n, scale); @@ -335,7 +338,7 @@ public void testLaplaceNoiseMeanNearZero() { @Test public void testLaplaceNoiseVarianceCorrect() { - // Var[Laplace(0, b)] = 2b². Allow 10% relative error for n=10000. + // Var[Laplace(0, b)] = 2b^2. Allow 10% relative error for n=10000. int n = 10_000; double scale = 1.5; double[] samples = sampleLaplace(n, scale); @@ -382,7 +385,7 @@ private static double[] sampleLaplace(int n, double scale) { return out; } - /** Sample n N(0, sigma²) values. */ + /** Sample n N(0, sigma^2) values. */ private static double[] sampleGaussian(int n, double sigma) { java.util.concurrent.ThreadLocalRandom rng = java.util.concurrent.ThreadLocalRandom.current(); double[] out = new double[n]; 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 index 92bd527afbe..6963f7bab2e 100644 --- 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 @@ -136,13 +136,13 @@ public void testGaussianIdentity() { @Test public void testHighEpsilonIsCloserToTruth() { double[][] data = TestUtils.generateTestMatrix(ROWS, COLS, 0, 1, 1.0, 42); - // Higher ε → less noise → result closer to the true mean. + // Higher epsilon => 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 + // (epsilon = 1.0) regardless of the per-release epsilon requested, so epsilon 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); + assertTrue("epsilon=0.5 should give less noise than epsilon=0.1", noisyHigh < noisyLow); } @Test @@ -168,7 +168,7 @@ public void testSetBudgetNarrowBudgetStillEnforced() { 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 + // 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); From fcf433881bbd261e62a1ac471ae166ac6501df78 Mon Sep 17 00:00:00 2001 From: Maya Anderson Date: Fri, 31 Jul 2026 21:39:29 +0200 Subject: [PATCH 30/43] Unite tests --- .../cp/DPBuiltinCPInstructionTest.java | 166 ++++++++---------- 1 file changed, 72 insertions(+), 94 deletions(-) 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 index 5723dde54dc..a0b4b9e414c 100755 --- a/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java +++ b/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java @@ -24,8 +24,12 @@ 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.instructions.cp.DPBuiltinCPInstruction; +import org.apache.sysds.runtime.matrix.data.MatrixBlock; import org.apache.sysds.runtime.privacy.dp.DPBudgetAccountant; +import java.lang.reflect.Method; + import org.junit.Test; import org.junit.Assert; @@ -40,7 +44,6 @@ * (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}. */ @@ -53,51 +56,43 @@ public class DPBuiltinCPInstructionTest { // ======================================================================= @Test - public void testAccountantInitialisesAtZeroCost() { + public void testAccountantInitialisesAtZeroCostThenAcceptsSingleReleases() { DPBudgetAccountant acc = new DPBudgetAccountant(1.0, 1e-5); // No releases yet: total cost should be a large negative number // (conversion formula gives -inf when rdpSum = 0 for all orders), // so remainingBudget() should exceed the budget. Assert.assertTrue("No releases should leave budget intact", acc.remainingBudget() > 0); Assert.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); + // epsilon=0.5, budget=1.0: one Laplace release should consume < budget. acc.compose(0.5, 0.0, 1.0); // Laplace, sensitivity=1 Assert.assertEquals(1, acc.releaseCount()); Assert.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 - Assert.assertEquals(1, acc.releaseCount()); - Assert.assertTrue("Single Gaussian release within budget", acc.totalEpsilonSpent() <= 1.0); + // Likewise, a single Gaussian release on a fresh accountant should stay within budget. + DPBudgetAccountant gaussianAcc = new DPBudgetAccountant(1.0, 1e-5); + gaussianAcc.compose(0.5, 1e-5, 1.0); // Gaussian + Assert.assertEquals(1, gaussianAcc.releaseCount()); + Assert.assertTrue("Single Gaussian release within budget", gaussianAcc.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. + // Budget = 0.5, but we try to make 10 releases at epsilon=0.1 each. // After enough releases the budget must be exceeded. - DPBudgetAccountant acc = new DPBudgetAccountant(0.1, 1e-5); + DPBudgetAccountant acc = new DPBudgetAccountant(0.5, 1e-5); + double prevEpsilonSpent = acc.totalEpsilonSpent(); + double prevRemainingBudget = acc.remainingBudget(); 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(); - Assert.assertTrue("Epsilon spent must increase with each release", current > prev); - prev = current; + acc.compose(0.1, 0.0, 1.0); // will throw before the 10th + double currentEpsilonSpent = acc.totalEpsilonSpent(); + double currentRemainingBudget = acc.remainingBudget(); + int releaseCount = acc.releaseCount(); + Assert.assertTrue("Epsilon spent must increase with each release", currentEpsilonSpent > prevEpsilonSpent); + Assert.assertTrue("Remaining budget must decrease", currentRemainingBudget < prevRemainingBudget); + Assert.assertEquals("Release count must match", i+1, releaseCount); + prevEpsilonSpent = currentEpsilonSpent; + prevRemainingBudget = currentRemainingBudget; } } @@ -124,18 +119,6 @@ public void testGaussianTighterThanLaplaceForSameEpsilon() { 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(); - Assert.assertTrue("Remaining budget must decrease", current < prev); - prev = current; - } - } - @Test public void testHigherEpsilonCostMoreForLaplace() { // For Laplace, the accountant uses basic (pure epsilon-DP) composition: cost = epsilon. @@ -153,24 +136,22 @@ public void testHigherEpsilonCostMoreForLaplace() { // --- 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 + public void testConstructorRejectsInvalidBudgetParameters() { + assertConstructorRejects(0.0, 1e-5); // zero epsilon budget + assertConstructorRejects(-0.5, 1e-5); // negative epsilon budget + assertConstructorRejects(1.0, 0.0); // delta = 0 + assertConstructorRejects(1.0, 1.0); // delta = 1 } - @Test(expected = DMLRuntimeException.class) - public void testConstructorRejectsDeltaOne() { - new DPBudgetAccountant(1.0, 1.0); + private static void assertConstructorRejects(double epsilonBudget, double delta) { + try { + new DPBudgetAccountant(epsilonBudget, delta); + Assert.fail("Expected DMLRuntimeException for epsilonBudget=" + epsilonBudget + ", delta=" + delta); + } + catch(DMLRuntimeException e) { + // expected + } } // ======================================================================= @@ -244,10 +225,12 @@ public void testConvenienceConstructorDefaultsDeltaTo1e5() { @Test(expected = DMLRuntimeException.class) public void testGaussianBudgetExhaustionThrows() { - // Budget = 0.1. Each Gaussian release costs more than 0.005, so 20 + // Budget = 0.5. 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++) { + System.out.println("totalEpsilonSpent: " + acc.totalEpsilonSpent()); + System.out.println("remainingBudget: " + acc.remainingBudget()); acc.compose(0.3, 1e-5, 1.0); } } @@ -326,42 +309,33 @@ public void testGaussianLargerEpsilonCostsMoreBudget() { // @Test - public void testLaplaceNoiseMeanNearZero() { - // For 10000 samples the empirical mean should be within 3*sigma/sqrt(n) of 0. + public void testLaplaceNoiseDistribution() throws ReflectiveOperationException { + // For 10000 samples the empirical mean should be within 5*sigma/sqrt(n) of 0, + // and Var[Laplace(0, b)] = 2b^2 should match within 10% relative error. int n = 10_000; - double scale = 2.0; + double scale = 1.5; double[] samples = sampleLaplace(n, scale); + double mean = mean(samples); double theoreticalStdErr = scale * Math.sqrt(2.0) / Math.sqrt(n); Assert.assertTrue("Laplace mean should be near 0", Math.abs(mean) < 5 * theoreticalStdErr); - } - @Test - public void testLaplaceNoiseVarianceCorrect() { - // Var[Laplace(0, b)] = 2b^2. 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; Assert.assertEquals("Laplace variance", expected, variance, 0.1 * expected); } @Test - public void testGaussianNoiseMeanNearZero() { + public void testGaussianNoiseDistribution() throws ReflectiveOperationException { + // Same idea as testLaplaceNoiseDistribution: check mean and variance from one sample set. int n = 10_000; - double sigma = 3.0; + double sigma = 2.0; double[] samples = sampleGaussian(n, sigma); + double mean = mean(samples); double theoreticalStdErr = sigma / Math.sqrt(n); Assert.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; Assert.assertEquals("Gaussian variance", expected, variance, 0.1 * expected); @@ -371,27 +345,31 @@ public void testGaussianNoiseVarianceCorrect() { // 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 Laplace(0, scale) values via the fillLaplaceNoise method. */ + private static double[] sampleLaplace(int n, double scale) throws ReflectiveOperationException { + return sampleViaReflection("fillLaplaceNoise", n, scale); + } + + /** Sample n N(0, sigma^2) values via the production fillGaussianNoise method. */ + private static double[] sampleGaussian(int n, double sigma) throws ReflectiveOperationException { + return sampleViaReflection("fillGaussianNoise", n, sigma); } - /** Sample n N(0, sigma^2) values. */ - private static double[] sampleGaussian(int n, double sigma) { - java.util.concurrent.ThreadLocalRandom rng = java.util.concurrent.ThreadLocalRandom.current(); + /** + * Invokes DPBuiltinCPInstruction's private fill*Noise(MatrixBlock, double) method to fill an + * n x 1 block, so the distribution tests exercise the actual noise generation code. + */ + private static double[] sampleViaReflection(String methodName, int n, double param) + throws ReflectiveOperationException { + MatrixBlock block = new MatrixBlock(n, 1, false); + block.allocateDenseBlock(); + Method m = DPBuiltinCPInstruction.class.getDeclaredMethod(methodName, MatrixBlock.class, double.class); + m.setAccessible(true); + m.invoke(null, block, param); + double[] out = new double[n]; - for(int i = 0; i < n; i++) { - out[i] = sigma * rng.nextGaussian(); - } + for(int i = 0; i < n; i++) + out[i] = block.get(i, 0); return out; } From 1e8f5d909a84dbfb2a8f996d226929c363a15cd8 Mon Sep 17 00:00:00 2001 From: Maya Anderson Date: Fri, 31 Jul 2026 21:39:54 +0200 Subject: [PATCH 31/43] Remove unneeded pom.xml change --- pom.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/pom.xml b/pom.xml index 22dbc5c2b9e..2560a38b7b7 100644 --- a/pom.xml +++ b/pom.xml @@ -408,7 +408,6 @@ maven-surefire-plugin ${maven-surefire-plugin.version} - plain ${maven.test.skip} ${test-parallel} ${test-threadCount} From 1c3562279d837ae212149c2709c21564e5fa69c1 Mon Sep 17 00:00:00 2001 From: Maya Anderson Date: Sat, 1 Aug 2026 13:04:30 +0200 Subject: [PATCH 32/43] Use getRandomMatrix in DPBuiltinDMLTest and call it in setup instead of in every test --- .../test/functions/privacy/dp/DPBuiltinDMLTest.java | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) 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 index 6963f7bab2e..c6750ceebd9 100644 --- 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 @@ -33,7 +33,6 @@ 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; /* @@ -56,6 +55,8 @@ public class DPBuiltinDMLTest extends AutomatedTestBase { private static final int ROWS = 100; private static final int COLS = 10; + private double[][] data; + 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"; @@ -92,6 +93,7 @@ public void setUp() { addTestConfiguration("DPLaplace", new TestConfiguration(TEST_CLASS, "DPLaplace")); addTestConfiguration("DPGaussian", new TestConfiguration(TEST_CLASS, "DPGaussian")); addTestConfiguration("DPSetBudget", new TestConfiguration(TEST_CLASS, "DPSetBudget")); + data = this.getRandomMatrix(ROWS, COLS, 0, 1, 1.0, 42); } @Test @@ -107,7 +109,6 @@ public void testGaussianOutputDiffersFromCleanMean() { @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); @@ -118,7 +119,6 @@ public void testLaplaceColSums() { @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); @@ -135,7 +135,6 @@ public void testGaussianIdentity() { @Test public void testHighEpsilonIsCloserToTruth() { - double[][] data = TestUtils.generateTestMatrix(ROWS, COLS, 0, 1, 1.0, 42); // Higher epsilon => less noise => result closer to the true mean. // NOTE: the DPBudgetAccountant caps total spend at the default budget // (epsilon = 1.0) regardless of the per-release epsilon requested, so epsilon values @@ -149,7 +148,6 @@ public void testHighEpsilonIsCloserToTruth() { 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); @@ -159,7 +157,6 @@ public void testSetBudgetLiteralAllowsExceedingDefaultBudget() { 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); } @@ -170,18 +167,15 @@ public void testSetBudgetCalledTwiceFailsAtCompileTime() { // 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. From 5f660cf0852aeec640fa9ea11ff549da2fbf5732 Mon Sep 17 00:00:00 2001 From: Maya Anderson Date: Sat, 1 Aug 2026 13:07:45 +0200 Subject: [PATCH 33/43] Removed python requirements file --- src/main/python/requirements.txt | 31 ------------------------------- 1 file changed, 31 deletions(-) delete mode 100644 src/main/python/requirements.txt diff --git a/src/main/python/requirements.txt b/src/main/python/requirements.txt deleted file mode 100644 index 5d17a102c29..00000000000 --- a/src/main/python/requirements.txt +++ /dev/null @@ -1,31 +0,0 @@ -#------------------------------------------------------------- -# -# 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 From 3e2f01ec8d2c5b305721d432cfa3e30cb38e6963 Mon Sep 17 00:00:00 2001 From: Maya Anderson Date: Sat, 1 Aug 2026 13:21:18 +0200 Subject: [PATCH 34/43] Reorder Gauss and Laplace cases alphabetically --- src/main/java/org/apache/sysds/common/Opcodes.java | 2 +- src/main/java/org/apache/sysds/common/Types.java | 6 +++--- .../apache/sysds/hops/ParameterizedBuiltinOp.java | 14 +++++++------- .../apache/sysds/lops/ParameterizedBuiltin.java | 8 ++++---- .../org/apache/sysds/parser/DMLTranslator.java | 2 +- .../ParameterizedBuiltinFunctionExpression.java | 4 ++-- .../instructions/cp/DPBuiltinCPInstruction.java | 2 +- 7 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/main/java/org/apache/sysds/common/Opcodes.java b/src/main/java/org/apache/sysds/common/Opcodes.java index f9ed57eae06..c614fb50460 100644 --- a/src/main/java/org/apache/sysds/common/Opcodes.java +++ b/src/main/java/org/apache/sysds/common/Opcodes.java @@ -195,8 +195,8 @@ public enum Opcodes { EINSUM("einsum", InstructionType.BuiltinNary), //DP built-in functions - DP_LAPLACE("dp_laplace", InstructionType.DPBuiltin), DP_GAUSSIAN("dp_gaussian", InstructionType.DPBuiltin), + DP_LAPLACE("dp_laplace", InstructionType.DPBuiltin), //Parametrized builtin functions AUTODIFF("autoDiff", 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 8c42d5c596d..fe6ac0f365c 100644 --- a/src/main/java/org/apache/sysds/common/Types.java +++ b/src/main/java/org/apache/sysds/common/Types.java @@ -807,10 +807,10 @@ public static ReOrgOp valueOfByOpcode(String opcode) { /** Parameterized operations that require named variable arguments */ public enum ParamBuiltinOp { AUTODIFF, CDF, CONTAINS, - // DP_LAPLACE/DP_GAUSSIAN reuse this Hop/Lop family (ParameterizedBuiltinOp, ParameterizedBuiltin Lop) + // DP_GAUSSIAN and DP_LAPLACE reuse this Hop/Lop family (ParameterizedBuiltinOp, ParameterizedBuiltin Lop) // but route to a distinct CPType.DPBuiltin/DPBuiltinCPInstruction at the CP-instruction layer instead - // of ParameterizedBuiltinCPInstruction (see Opcodes.DP_LAPLACE / Opcodes.DP_GAUSSIAN). - DP_LAPLACE, DP_GAUSSIAN, + // of ParameterizedBuiltinCPInstruction (see Opcodes.DP_GAUSSIAN, Opcodes.DP_LAPLACE). + DP_GAUSSIAN, DP_LAPLACE, 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 393d1244c0a..ae8742657c2 100644 --- a/src/main/java/org/apache/sysds/hops/ParameterizedBuiltinOp.java +++ b/src/main/java/org/apache/sysds/hops/ParameterizedBuiltinOp.java @@ -195,8 +195,8 @@ public Lop constructLops() case PARAMSERV: case LIST: case AUTODIFF: - case DP_LAPLACE: - case DP_GAUSSIAN: { + case DP_GAUSSIAN: + case DP_LAPLACE: { ParameterizedBuiltin pbilop = new ParameterizedBuiltin(inputlops, _op, getDataType(), getValueType(), et); if(isMultiThreadedOpType()) @@ -690,7 +690,7 @@ 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) { + else if(_op == ParamBuiltinOp.DP_GAUSSIAN || _op == ParamBuiltinOp.DP_LAPLACE) { if(dc.dimsKnown()) { Hop query = getParameterHop("query"); String queryVal = (query instanceof LiteralOp) ? ((LiteralOp) query).getStringValue() : null; @@ -769,8 +769,8 @@ && getTargetHop().areDimsBelowThreshold() ) { // to determine the local or remote workers if(_op == ParamBuiltinOp.TRANSFORMCOLMAP || _op == ParamBuiltinOp.TRANSFORMMETA || _op == ParamBuiltinOp.TOSTRING || _op == ParamBuiltinOp.LIST || _op == ParamBuiltinOp.CDF || - _op == ParamBuiltinOp.INVCDF || _op == ParamBuiltinOp.PARAMSERV || _op == ParamBuiltinOp.DP_LAPLACE || - _op == ParamBuiltinOp.DP_GAUSSIAN) { + _op == ParamBuiltinOp.INVCDF || _op == ParamBuiltinOp.PARAMSERV || _op == ParamBuiltinOp.DP_GAUSSIAN || + _op == ParamBuiltinOp.DP_LAPLACE) { _etype = ExecType.CP; } @@ -953,10 +953,10 @@ public boolean compare( Hop that ) if( !(that instanceof ParameterizedBuiltinOp) ) return false; - // NOTE: dp_laplace/dp_gaussian draw fresh random noise on every call and record a + // NOTE: dp_gaussian, dp_laplace draw fresh random noise on every call and record a // privacy-budget charge as a side effect (see DPBuiltinCPInstruction), so two // syntactically identical calls must never be merged into one execution. - if( _op == ParamBuiltinOp.DP_LAPLACE || _op == ParamBuiltinOp.DP_GAUSSIAN ) + if( _op == ParamBuiltinOp.DP_GAUSSIAN || _op == ParamBuiltinOp.DP_LAPLACE ) return false; ParameterizedBuiltinOp that2 = (ParameterizedBuiltinOp)that; diff --git a/src/main/java/org/apache/sysds/lops/ParameterizedBuiltin.java b/src/main/java/org/apache/sysds/lops/ParameterizedBuiltin.java index 48ecbaa72df..b71fdf3423b 100644 --- a/src/main/java/org/apache/sysds/lops/ParameterizedBuiltin.java +++ b/src/main/java/org/apache/sysds/lops/ParameterizedBuiltin.java @@ -204,14 +204,14 @@ public String getInstructions(String output) compileGenericParamMap(sb, _inputParams); break; } - case DP_LAPLACE: { - sb.append(Opcodes.DP_LAPLACE); + case DP_GAUSSIAN: { + sb.append(Opcodes.DP_GAUSSIAN); sb.append(OPERAND_DELIMITOR); compileGenericParamMap(sb, _inputParams); break; } - case DP_GAUSSIAN: { - sb.append(Opcodes.DP_GAUSSIAN); + case DP_LAPLACE: { + sb.append(Opcodes.DP_LAPLACE); sb.append(OPERAND_DELIMITOR); compileGenericParamMap(sb, _inputParams); break; diff --git a/src/main/java/org/apache/sysds/parser/DMLTranslator.java b/src/main/java/org/apache/sysds/parser/DMLTranslator.java index 07d52e0af47..573e6697c13 100644 --- a/src/main/java/org/apache/sysds/parser/DMLTranslator.java +++ b/src/main/java/org/apache/sysds/parser/DMLTranslator.java @@ -2014,8 +2014,8 @@ private Hop processParameterizedBuiltinFunctionExpression(ParameterizedBuiltinFu case TRANSFORMMETA: case PARAMSERV: case AUTODIFF: - case DP_LAPLACE: case DP_GAUSSIAN: + case DP_LAPLACE: currBuiltinOp = new ParameterizedBuiltinOp(target.getName(), target.getDataType(), target.getValueType(), ParamBuiltinOp.valueOf(source.getOpCode().name()), paramHops); break; diff --git a/src/main/java/org/apache/sysds/parser/ParameterizedBuiltinFunctionExpression.java b/src/main/java/org/apache/sysds/parser/ParameterizedBuiltinFunctionExpression.java index f6f9ad4a268..30622c85096 100644 --- a/src/main/java/org/apache/sysds/parser/ParameterizedBuiltinFunctionExpression.java +++ b/src/main/java/org/apache/sysds/parser/ParameterizedBuiltinFunctionExpression.java @@ -268,8 +268,8 @@ public void validateExpression(HashMap ids, HashMap Date: Mon, 3 Aug 2026 00:37:13 +0200 Subject: [PATCH 35/43] Add parsing tests --- .../cp/DPBuiltinCPInstructionTest.java | 150 +++++++----------- 1 file changed, 53 insertions(+), 97 deletions(-) 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 index a0b4b9e414c..5f6269756e2 100755 --- a/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java +++ b/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java @@ -19,11 +19,7 @@ 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.instructions.cp.DPBuiltinCPInstruction; import org.apache.sysds.runtime.matrix.data.MatrixBlock; import org.apache.sysds.runtime.privacy.dp.DPBudgetAccountant; @@ -36,12 +32,14 @@ /** * Tests for DPBuiltinCPInstruction and DPBudgetAccountant. * - * The tests are grouped into three levels: + * The tests are grouped into four 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). + * - DPBuiltinCPInstruction structural tests - exercise parseInstruction()'s required-parameter + * validation. * - 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 @@ -154,83 +152,12 @@ private static void assertConstructorRejects(double epsilonBudget, double delta) } } - // ======================================================================= - // 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(); - Assert.assertFalse("No dp_set_budget call yet", dmlProg.hasDPBudget()); - dmlProg.setDPBudget(2.0, 1e-6); - Assert.assertTrue("dp_set_budget was called", dmlProg.hasDPBudget()); - Assert.assertEquals(2.0, dmlProg.getDPBudgetEpsilon(), EPS); - Assert.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 - Assert.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); - Assert.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); - Assert.assertEquals("Convenience constructor must default to delta=1e-5", twoArg.totalEpsilonSpent(), - oneArg.totalEpsilonSpent(), EPS); - } - @Test(expected = DMLRuntimeException.class) public void testGaussianBudgetExhaustionThrows() { - // Budget = 0.5. 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); + // Budget = 0.5. Each Gaussian release is composed at epsilon=0.3, delta=1e-5, + // so the RDP-converted cost accumulates well past the budget within 20 releases. + DPBudgetAccountant acc = new DPBudgetAccountant(0.5, 1e-5); for(int i = 0; i < 20; i++) { - System.out.println("totalEpsilonSpent: " + acc.totalEpsilonSpent()); - System.out.println("remainingBudget: " + acc.remainingBudget()); acc.compose(0.3, 1e-5, 1.0); } } @@ -256,24 +183,6 @@ public void testMixedCompositionExceedsEitherAlone() { mixed.totalEpsilonSpent() > gauOnly.totalEpsilonSpent()); } - // --- Release count across multiple mixed releases -------------- - - @Test - public void testReleaseCountTracksAllReleases() { - DPBudgetAccountant acc = new DPBudgetAccountant(100.0, 1e-5); - Assert.assertEquals(0, acc.releaseCount()); - acc.compose(0.1, 0.0, 1.0); // Laplace - Assert.assertEquals(1, acc.releaseCount()); - acc.compose(0.1, 1e-5, 1.0); // Gaussian - Assert.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 - Assert.assertEquals(5, acc.releaseCount()); - } - - // --- Edge-case inputs for rdpGaussian / gaussianSigma ---------- - @Test public void testGaussianSensitivityCancelsInRDP() { // For the Gaussian mechanism: sigma = delta_f*sqrt(2*ln(1.25/delta))/epsilon, so @@ -341,6 +250,53 @@ public void testGaussianNoiseDistribution() throws ReflectiveOperationException Assert.assertEquals("Gaussian variance", expected, variance, 0.1 * expected); } + // ======================================================================= + // 3. DPBuiltinCPInstruction structural tests + // ======================================================================= + + @Test + public void testParseInstructionValidLaplace() { + DPBuiltinCPInstruction inst = DPBuiltinCPInstruction + .parseInstruction("CP°dp_laplace°target=mVar1°query=colMeans°sensitivity=1.0°epsilon=0.5°_mVar2·MATRIX·FP64"); + Assert.assertEquals(DPBuiltinCPInstruction.OPCODE_LAPLACE, inst.getOpcode()); + Assert.assertEquals("mVar1", inst.input1.getName()); + Assert.assertEquals("_mVar2", inst.getOutput().getName()); + } + + @Test + public void testParseInstructionValidGaussian() { + DPBuiltinCPInstruction inst = DPBuiltinCPInstruction.parseInstruction( + "CP°dp_gaussian°target=mVar1°query=colMeans°sensitivity=1.0°epsilon=0.5°delta=1e-5°_mVar2·MATRIX·FP64"); + Assert.assertEquals(DPBuiltinCPInstruction.OPCODE_GAUSSIAN, inst.getOpcode()); + Assert.assertEquals("mVar1", inst.input1.getName()); + Assert.assertEquals("_mVar2", inst.getOutput().getName()); + } + + @Test + public void testParseInstructionMissingRequiredKeysThrows() { + // Each string below keeps the field COUNT that checkNumFields expects (5 for laplace, + // 6 for gaussian), but renames one required key so parseInstruction's own + // containsKey(...) checks - not checkNumFields - are what reject it. + assertParseInstructionRejects( + "CP°dp_laplace°target=mVar1°qry=colMeans°sensitivity=1.0°epsilon=0.5°_mVar2·MATRIX·FP64"); // missing query + assertParseInstructionRejects( + "CP°dp_laplace°target=mVar1°query=colMeans°sens=1.0°epsilon=0.5°_mVar2·MATRIX·FP64"); // missing sensitivity + assertParseInstructionRejects( + "CP°dp_laplace°target=mVar1°query=colMeans°sensitivity=1.0°eps=0.5°_mVar2·MATRIX·FP64"); // missing epsilon + assertParseInstructionRejects("CP°dp_gaussian°target=mVar1°query=colMeans°sensitivity=1.0°epsilon=0.5°" + + "notdelta=1e-5°_mVar2·MATRIX·FP64"); // missing delta (gaussian only) + } + + private static void assertParseInstructionRejects(String instStr) { + try { + DPBuiltinCPInstruction.parseInstruction(instStr); + Assert.fail("Expected DMLRuntimeException for instruction: " + instStr); + } + catch(DMLRuntimeException e) { + // expected + } + } + // ----------------------------------------------------------------------- // Helpers for noise distribution tests // ----------------------------------------------------------------------- From 1103eb6914b53699e887a1e0528edb9a474a8c2e Mon Sep 17 00:00:00 2001 From: Maya Anderson Date: Mon, 3 Aug 2026 00:59:08 +0200 Subject: [PATCH 36/43] Use computeGaussianSigma --- .../cp/DPBuiltinCPInstruction.java | 68 +++++++++++++++++-- .../privacy/dp/DPBudgetAccountant.java | 30 +++----- 2 files changed, 75 insertions(+), 23 deletions(-) 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 index 61af0bfd060..fb4372a98c7 100755 --- a/src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinCPInstruction.java +++ b/src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinCPInstruction.java @@ -19,6 +19,8 @@ package org.apache.sysds.runtime.instructions.cp; +import org.apache.commons.math3.distribution.NormalDistribution; + import org.apache.sysds.runtime.DMLRuntimeException; import org.apache.sysds.runtime.controlprogram.context.ExecutionContext; import org.apache.sysds.runtime.instructions.InstructionUtils; @@ -77,6 +79,9 @@ public class DPBuiltinCPInstruction extends ComputationCPInstruction { */ private final LinkedHashMap _params; + private static final NormalDistribution normal = new NormalDistribution(); + + // ----------------------------------------------------------------------- // Constructor (private – use parseInstruction) // ----------------------------------------------------------------------- @@ -295,10 +300,10 @@ private MatrixBlock generateNoise(int rows, int cols, double sensitivity, double 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; + // Gaussian mechanism + // For a given epsilon and delta, noise is drawn from the Gaussian distribution + // N(0, sigma^2) + double sigma = computeGaussianSigma(sensitivity, epsilon, delta); fillGaussianNoise(noise, sigma); } @@ -306,6 +311,61 @@ private MatrixBlock generateNoise(int rows, int cols, double sensitivity, double return noise; } + // /** + // * Gaussian mechanism: calibrate sigma for (epsilon, delta)-DP. + // * Classical Gaussian mechanism calibration + // * + // * @param sensitivity L2 sensitivity + // * @param epsilon target epsilon + // * @param delta target delta + // * @return optimal sigma + // */ + // public static double getGaussianSigma(double sensitivity, double epsilon, double delta) { + // double sigma = sensitivity * Math.sqrt(2.0 * Math.log(1.25 / delta)) / epsilon; + // return sigma; + // } + + /** + * Compute the optimal sigma for the Analytic Gaussian Mechanism (Balle & Wang 2018). + * Returns the smallest sigma such that the Gaussian mechanism is (epsilon, delta)-DP. + * + * @param sensitivity L2 sensitivity + * @param epsilon target epsilon + * @param delta target delta + * @return optimal sigma + */ + public static double computeGaussianSigma(double sensitivity, double epsilon, double delta) { + + // Upper bound: classical Gaussian mechanism (loose but safe) + double sigmaHigh = (sensitivity * Math.sqrt(2 * Math.log(1.25 / delta))) / epsilon; + double sigmaLow = 1e-12; + + for (int i = 0; i < 100; i++) { + double sigmaMid = 0.5 * (sigmaLow + sigmaHigh); + + if (deltaUpperBound(sigmaMid, epsilon, delta, sensitivity) > delta) { + sigmaLow = sigmaMid; + } else { + sigmaHigh = sigmaMid; + } + } + + return sigmaHigh; + } + + /** + * Analytic Gaussian DP inequality from Balle & Wang (2018). + */ + private static double deltaUpperBound(double sigma, double epsilon, double delta, double sensitivity) { + double c = sensitivity / (2 * sigma); + + double term1 = normal.cumulativeProbability(c - epsilon * sigma / sensitivity); + double term2 = Math.exp(epsilon) * + normal.cumulativeProbability(-c - epsilon * sigma / sensitivity); + + return term1 - term2; + } + /** * Fills block with i.i.d. Laplace(0, scale) samples using the inverse-CDF method. * 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 index c00ca317ab0..c2fd4bf4d9f 100644 --- a/src/main/java/org/apache/sysds/runtime/privacy/dp/DPBudgetAccountant.java +++ b/src/main/java/org/apache/sysds/runtime/privacy/dp/DPBudgetAccountant.java @@ -52,7 +52,7 @@ * sensitivity cancels in the final expression, so the RDP cost depends only on the (epsilon, delta) parameters. * * RDP => (epsilon, delta) conversion (Mironov 2017, Proposition 3): - * epsilon(alpha) = R[alpha] + log(1 − 1/alpha) − log(delta*(alpha−1)) / alpha + * epsilon(alpha) = R[alpha] + log(1/delta) / (alpha − 1) * * One instance is created per ExecutionContext (lazy init). It is garbage-collected with the context when the * script finishes; no state leaks between script executions or between concurrent scripts. @@ -103,6 +103,9 @@ public class DPBudgetAccountant { /** Number of releases recorded so far (for error messages). */ private int _releaseCount = 0; + /** Whether at least one Gaussian release has been recorded. */ + private boolean _hasGaussianReleases = false; + // ----------------------------------------------------------------------- // Constructors // ----------------------------------------------------------------------- @@ -173,8 +176,9 @@ public void compose(double epsilon, double delta, double sensitivity) { } else { // Gaussian: accumulate Renyi divergence at each order, then convert. + _hasGaussianReleases = true; for(int i = 0; i < ORDERS.length; i++) { - double sigma = gaussianSigma(sensitivity, epsilon, delta); + double sigma = DPBuiltinCPInstruction.computeGaussianSigma(sensitivity, epsilon, delta); _rdpSum[i] += rdpGaussian(ORDERS[i], sensitivity, sigma); } } @@ -199,20 +203,18 @@ public void compose(double epsilon, double delta, double sensitivity) { * recorded). */ public double totalEpsilonSpent() { + if(!_hasGaussianReleases) + return _pureEpsilonSum; + // Take min_alpha(epsilon_alpha) 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; + double eps = _rdpSum[i] + Math.log(1.0 / _delta) / (alpha - 1.0); 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 delta they never requested. - if(gaussianEps < 0) - gaussianEps = 0.0; - return _pureEpsilonSum + gaussianEps; + return _pureEpsilonSum + Math.max(gaussianEps, 0.0); } /** Returns the remaining epsilon budget (negative if the budget is exceeded). */ @@ -236,14 +238,4 @@ public int releaseCount() { private static double rdpGaussian(double alpha, double sensitivity, double sigma) { return alpha * (sensitivity * sensitivity) / (2.0 * sigma * sigma); } - - /** - * Gaussian noise scale sigma calibrated to (epsilon, delta)-DP: - * sigma = delta_f * sqrt(2 * log(1.25 / delta)) / epsilon - * 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; - } } From 8774f3f060160300a20593e156fbcc493235fd25 Mon Sep 17 00:00:00 2001 From: Maya Anderson Date: Mon, 3 Aug 2026 01:34:23 +0200 Subject: [PATCH 37/43] Change gaussian noise generation to use MatrixBlock randOperation, and laplace - Well1024a --- .../cp/DPBuiltinCPInstruction.java | 55 +++++++++---------- .../cp/DPBuiltinCPInstructionTest.java | 25 ++++----- 2 files changed, 36 insertions(+), 44 deletions(-) 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 index fb4372a98c7..eeb34e4e880 100755 --- a/src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinCPInstruction.java +++ b/src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinCPInstruction.java @@ -19,18 +19,21 @@ package org.apache.sysds.runtime.instructions.cp; +import org.apache.commons.math3.distribution.LaplaceDistribution; import org.apache.commons.math3.distribution.NormalDistribution; +import org.apache.commons.math3.random.Well1024a; import org.apache.sysds.runtime.DMLRuntimeException; import org.apache.sysds.runtime.controlprogram.context.ExecutionContext; +import org.apache.sysds.runtime.functionobjects.Multiply; 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.matrix.operators.RightScalarOperator; 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. @@ -290,24 +293,22 @@ private double sensitivityOf(MatrixBlock T) { */ private MatrixBlock generateNoise(int rows, int cols, double sensitivity, double epsilon, double delta) { - MatrixBlock noise = new MatrixBlock(rows, cols, false); // dense - noise.allocateDenseBlock(); + MatrixBlock noise; 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); + noise = fillLaplaceNoise(rows, cols, sensitivity / epsilon); } else { // Gaussian mechanism // For a given epsilon and delta, noise is drawn from the Gaussian distribution // N(0, sigma^2) double sigma = computeGaussianSigma(sensitivity, epsilon, delta); - fillGaussianNoise(noise, sigma); + noise = fillGaussianNoise(rows, cols, sigma); } - noise.recomputeNonZeros(); return noise; } @@ -367,41 +368,35 @@ private static double deltaUpperBound(double sigma, double epsilon, double delta } /** - * Fills block with i.i.d. Laplace(0, scale) samples using the inverse-CDF method. + * Fills block with i.i.d. Laplace(0, scale) samples. * - * For u in Uniform(0, 1): X = -scale * sign(u - 0.5) * ln(1 - 2|u - 0.5|) + * Draws from commons-math3's {@link LaplaceDistribution} (its inverse-CDF sampling, tested independently + * of this class) seeded by {@link Well1024a}, the same long-period equidistributed generator + * {@link org.apache.sysds.runtime.matrix.data.LibMatrixDatagen} uses for DML's rand() builtin. */ - private static void fillLaplaceNoise(MatrixBlock block, double scale) { - ThreadLocalRandom rng = ThreadLocalRandom.current(); - int rows = block.getNumRows(); - int cols = block.getNumColumns(); + private static MatrixBlock fillLaplaceNoise(int rows, int cols, double scale) { + MatrixBlock noise = new MatrixBlock(rows, cols, false); // dense + noise.allocateDenseBlock(); + LaplaceDistribution laplace = new LaplaceDistribution(new Well1024a(), 0, scale); 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); + noise.set(r, c, laplace.sample()); } } + return noise; } /** - * Fills block with i.i.d. N(0, sigma^2) samples. + * Generates a rows x cols block of i.i.d. N(0, sigma^2) samples. * - * Uses {@link ThreadLocalRandom#nextGaussian()} which is thread-safe and does not require external libraries. + * Reuses the same Well1024a-seeded, Box-Muller normal generator that backs DML's rand(pdf="normal") + * (see {@link MatrixBlock#randOperations}), so the noise gets the same long-period PRNG and block-parallel generation + * as the rest of SystemDS's random matrix generation. randOperations produces standard N(0,1) samples + * (pdf="normal" ignores min/max), so the sigma scaling is applied afterwards as a scalar multiply. */ - 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()); - } - } + private static MatrixBlock fillGaussianNoise(int rows, int cols, double sigma) { + MatrixBlock std = MatrixBlock.randOperations(rows, cols, 1.0, 0, 1, "normal", -1); + return std.scalarOperations(new RightScalarOperator(Multiply.getMultiplyFnObject(), sigma), null); } // ----------------------------------------------------------------------- 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 index 5f6269756e2..b07e2201dc9 100755 --- a/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java +++ b/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java @@ -303,25 +303,22 @@ private static void assertParseInstructionRejects(String instStr) { /** Sample n Laplace(0, scale) values via the fillLaplaceNoise method. */ private static double[] sampleLaplace(int n, double scale) throws ReflectiveOperationException { - return sampleViaReflection("fillLaplaceNoise", n, scale); + Method m = DPBuiltinCPInstruction.class.getDeclaredMethod("fillLaplaceNoise", int.class, int.class, double.class); + m.setAccessible(true); + MatrixBlock block = (MatrixBlock) m.invoke(null, n, 1, scale); + + double[] out = new double[n]; + for(int i = 0; i < n; i++) + out[i] = block.get(i, 0); + return out; } /** Sample n N(0, sigma^2) values via the production fillGaussianNoise method. */ private static double[] sampleGaussian(int n, double sigma) throws ReflectiveOperationException { - return sampleViaReflection("fillGaussianNoise", n, sigma); - } - - /** - * Invokes DPBuiltinCPInstruction's private fill*Noise(MatrixBlock, double) method to fill an - * n x 1 block, so the distribution tests exercise the actual noise generation code. - */ - private static double[] sampleViaReflection(String methodName, int n, double param) - throws ReflectiveOperationException { - MatrixBlock block = new MatrixBlock(n, 1, false); - block.allocateDenseBlock(); - Method m = DPBuiltinCPInstruction.class.getDeclaredMethod(methodName, MatrixBlock.class, double.class); + Method m = DPBuiltinCPInstruction.class.getDeclaredMethod("fillGaussianNoise", int.class, int.class, + double.class); m.setAccessible(true); - m.invoke(null, block, param); + MatrixBlock block = (MatrixBlock) m.invoke(null, n, 1, sigma); double[] out = new double[n]; for(int i = 0; i < n; i++) From 61497764d77be32f112fb095cbb9c1222a78a5ec Mon Sep 17 00:00:00 2001 From: Maya Anderson Date: Mon, 3 Aug 2026 01:45:22 +0200 Subject: [PATCH 38/43] Checkstyle --- .../cp/DPBuiltinCPInstruction.java | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) 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 index eeb34e4e880..96763c11b92 100755 --- a/src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinCPInstruction.java +++ b/src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinCPInstruction.java @@ -335,37 +335,37 @@ private MatrixBlock generateNoise(int rows, int cols, double sensitivity, double * @param delta target delta * @return optimal sigma */ - public static double computeGaussianSigma(double sensitivity, double epsilon, double delta) { + public static double computeGaussianSigma(double sensitivity, double epsilon, double delta) { - // Upper bound: classical Gaussian mechanism (loose but safe) - double sigmaHigh = (sensitivity * Math.sqrt(2 * Math.log(1.25 / delta))) / epsilon; - double sigmaLow = 1e-12; + // Upper bound: classical Gaussian mechanism (loose but safe) + double sigmaHigh = (sensitivity * Math.sqrt(2 * Math.log(1.25 / delta))) / epsilon; + double sigmaLow = 1e-12; - for (int i = 0; i < 100; i++) { - double sigmaMid = 0.5 * (sigmaLow + sigmaHigh); + for (int i = 0; i < 100; i++) { + double sigmaMid = 0.5 * (sigmaLow + sigmaHigh); - if (deltaUpperBound(sigmaMid, epsilon, delta, sensitivity) > delta) { - sigmaLow = sigmaMid; - } else { - sigmaHigh = sigmaMid; - } - } + if (deltaUpperBound(sigmaMid, epsilon, delta, sensitivity) > delta) { + sigmaLow = sigmaMid; + } else { + sigmaHigh = sigmaMid; + } + } - return sigmaHigh; - } + return sigmaHigh; + } /** * Analytic Gaussian DP inequality from Balle & Wang (2018). */ - private static double deltaUpperBound(double sigma, double epsilon, double delta, double sensitivity) { - double c = sensitivity / (2 * sigma); + private static double deltaUpperBound(double sigma, double epsilon, double delta, double sensitivity) { + double c = sensitivity / (2 * sigma); - double term1 = normal.cumulativeProbability(c - epsilon * sigma / sensitivity); - double term2 = Math.exp(epsilon) * - normal.cumulativeProbability(-c - epsilon * sigma / sensitivity); + double term1 = normal.cumulativeProbability(c - epsilon * sigma / sensitivity); + double term2 = Math.exp(epsilon) * + normal.cumulativeProbability(-c - epsilon * sigma / sensitivity); - return term1 - term2; - } + return term1 - term2; + } /** * Fills block with i.i.d. Laplace(0, scale) samples. From baf15c0a835e19c3d7d51f3fa8aee2d1f16fbdb4 Mon Sep 17 00:00:00 2001 From: Maya Anderson Date: Tue, 4 Aug 2026 00:29:54 +0200 Subject: [PATCH 39/43] use an existing ParameterizedBuiltin type, instead of a new instruction type DPBuiltin --- .../apache/sysds/common/InstructionType.java | 1 - .../java/org/apache/sysds/common/Opcodes.java | 4 +- .../java/org/apache/sysds/common/Types.java | 6 +- .../sysds/hops/ParameterizedBuiltinOp.java | 2 +- .../instructions/CPInstructionParser.java | 6 +- .../instructions/cp/CPInstruction.java | 13 +- ...inCPInstruction.java => DPBuiltinOps.java} | 170 ++++-------------- .../cp/ParameterizedBuiltinCPInstruction.java | 27 ++- .../privacy/dp/DPBudgetAccountant.java | 6 +- ...ructionTest.java => DPBuiltinOpsTest.java} | 47 ++--- 10 files changed, 105 insertions(+), 177 deletions(-) rename src/main/java/org/apache/sysds/runtime/instructions/cp/{DPBuiltinCPInstruction.java => DPBuiltinOps.java} (64%) rename src/test/java/org/apache/sysds/test/component/cp/{DPBuiltinCPInstructionTest.java => DPBuiltinOpsTest.java} (88%) diff --git a/src/main/java/org/apache/sysds/common/InstructionType.java b/src/main/java/org/apache/sysds/common/InstructionType.java index ed795d038e2..e0e77c46c59 100644 --- a/src/main/java/org/apache/sysds/common/InstructionType.java +++ b/src/main/java/org/apache/sysds/common/InstructionType.java @@ -63,7 +63,6 @@ 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 c614fb50460..f68cf00ca23 100644 --- a/src/main/java/org/apache/sysds/common/Opcodes.java +++ b/src/main/java/org/apache/sysds/common/Opcodes.java @@ -195,8 +195,8 @@ public enum Opcodes { EINSUM("einsum", InstructionType.BuiltinNary), //DP built-in functions - DP_GAUSSIAN("dp_gaussian", InstructionType.DPBuiltin), - DP_LAPLACE("dp_laplace", InstructionType.DPBuiltin), + DP_GAUSSIAN("dp_gaussian", InstructionType.ParameterizedBuiltin), + DP_LAPLACE("dp_laplace", InstructionType.ParameterizedBuiltin), //Parametrized builtin functions AUTODIFF("autoDiff", 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 fe6ac0f365c..ba54498a731 100644 --- a/src/main/java/org/apache/sysds/common/Types.java +++ b/src/main/java/org/apache/sysds/common/Types.java @@ -806,11 +806,7 @@ public static ReOrgOp valueOfByOpcode(String opcode) { /** Parameterized operations that require named variable arguments */ public enum ParamBuiltinOp { - AUTODIFF, CDF, CONTAINS, - // DP_GAUSSIAN and DP_LAPLACE reuse this Hop/Lop family (ParameterizedBuiltinOp, ParameterizedBuiltin Lop) - // but route to a distinct CPType.DPBuiltin/DPBuiltinCPInstruction at the CP-instruction layer instead - // of ParameterizedBuiltinCPInstruction (see Opcodes.DP_GAUSSIAN, Opcodes.DP_LAPLACE). - DP_GAUSSIAN, DP_LAPLACE, + AUTODIFF, CDF, CONTAINS, DP_GAUSSIAN, DP_LAPLACE, 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 ae8742657c2..cd37fc98f67 100644 --- a/src/main/java/org/apache/sysds/hops/ParameterizedBuiltinOp.java +++ b/src/main/java/org/apache/sysds/hops/ParameterizedBuiltinOp.java @@ -954,7 +954,7 @@ public boolean compare( Hop that ) return false; // NOTE: dp_gaussian, dp_laplace draw fresh random noise on every call and record a - // privacy-budget charge as a side effect (see DPBuiltinCPInstruction), so two + // privacy-budget charge as a side effect (see DPBuiltinOps.release), so two // syntactically identical calls must never be merged into one execution. if( _op == ParamBuiltinOp.DP_GAUSSIAN || _op == ParamBuiltinOp.DP_LAPLACE ) return false; 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 ca6baf058b4..92e11b425dd 100644 --- a/src/main/java/org/apache/sysds/runtime/instructions/CPInstructionParser.java +++ b/src/main/java/org/apache/sysds/runtime/instructions/CPInstructionParser.java @@ -65,7 +65,6 @@ 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; @@ -227,10 +226,7 @@ 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 e45e17ac175..b8d84ca3898 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 @@ -42,11 +42,14 @@ public enum CPType { AggregateUnary, AggregateBinary, AggregateTernary, Unary, Binary, Ternary, Quaternary, BuiltinNary, Ctable, MultiReturnParameterizedBuiltin, ParameterizedBuiltin, MultiReturnBuiltin, MultiReturnComplexMatrixBuiltin, - Builtin, Reorg, Variable, FCall, Append, Rand, QSort, QPick, Local, MatrixIndexing, MMTSJ, PMMJ, MMChain, - Reshape, Partition, Compression, DeCompression, SpoofFused, StringInit, CentralMoment, Covariance, - UaggOuterChain, Dnn, Sql, Prefetch, Broadcast, TrigRemote, EvictLineageCache, EINSUM, NoOp, Union, - QuantizeCompression, DPBuiltin - } + Builtin, Reorg, Variable, FCall, Append, Rand, QSort, QPick, Local, + MatrixIndexing, MMTSJ, PMMJ, MMChain, Reshape, Partition, Compression, DeCompression, SpoofFused, + StringInit, CentralMoment, Covariance, UaggOuterChain, Dnn, Sql, Prefetch, Broadcast, TrigRemote, + EvictLineageCache, EINSUM, + NoOp, + Union, + QuantizeCompression + } protected final CPType _cptype; protected final boolean _requiresLabelUpdate; diff --git a/src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinCPInstruction.java b/src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinOps.java similarity index 64% rename from src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinCPInstruction.java rename to src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinOps.java index 96763c11b92..2930f92c2e6 100755 --- a/src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinCPInstruction.java +++ b/src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinOps.java @@ -23,30 +23,30 @@ import org.apache.commons.math3.distribution.NormalDistribution; import org.apache.commons.math3.random.Well1024a; +import org.apache.sysds.common.Opcodes; import org.apache.sysds.runtime.DMLRuntimeException; -import org.apache.sysds.runtime.controlprogram.context.ExecutionContext; import org.apache.sysds.runtime.functionobjects.Multiply; -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.matrix.operators.RightScalarOperator; import org.apache.sysds.runtime.privacy.dp.DPBudgetAccountant; -import java.util.LinkedHashMap; +import java.util.Map; /** - * CP instruction for differential-privacy release of a linear query over the original matrix. + * Differential-privacy release of a linear query over the original matrix, invoked from + * {@link ParameterizedBuiltinCPInstruction} for the {@code dp_laplace}/{@code dp_gaussian} opcodes. * * 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 n x d} matrix X, builds a transformation matrix T + * {@link #release} receives the original n x d matrix X, builds a transformation matrix T * (k x n) from the named query (see {@link #buildTransform}), and returns a noisy release of * T %*% X. The noise is not added as a separate elementwise pass over a materialised aggregate: it is injected * by augmenting T with an identity block and 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). + * result of a single {@link LibMatrixMult#matrixMult} call (see {@link #release} for the derivation). * * Sensitivity norm: sensitivity is not interchangeable between the two builtins. dp_laplace calibrates * its noise scale to the L1 sensitivity of T %*% X to a single-record change; dp_gaussian calibrates @@ -59,85 +59,12 @@ * analysis that derives sensitivity from T's column norms and a declared per-record bound on 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_GAUSSIAN = "dp_gaussian"; - public static final String OPCODE_LAPLACE = "dp_laplace"; - - // ----------------------------------------------------------------------- - // 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; +public class DPBuiltinOps { private static final NormalDistribution normal = new NormalDistribution(); - - // ----------------------------------------------------------------------- - // 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 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); + private DPBuiltinOps() { + // static utility class } // ----------------------------------------------------------------------- @@ -147,56 +74,50 @@ public static DPBuiltinCPInstruction parseInstruction(String str) { /** * Executes the DP release. * - * - Read the original {@link MatrixBlock} X from the variable table. * - Build the transformation matrix T (k x n) from query (see {@link #buildTransform}). * - Determine sensitivity via {@link #sensitivityOf}. * - Generate a noise {@link MatrixBlock} shaped k x d. * - Fuse 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 T (k x n), X (n x d) and noise N (k x d), * let T' = [T | I_k] (k x (n+k)) and X' = [X ; N] ((n+k) x d). Then * 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. + * + * @param X the original input matrix (caller pins/releases it around this call) + * @param opcode dp_laplace or dp_gaussian + * @param params named parameters: "query", "sensitivity", "epsilon", "delta" (Gaussian only) + * @param accountant the session-scoped privacy budget accountant to charge this release against + * @return the noisy release T %*% X + N */ - @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")); + static MatrixBlock release(MatrixBlock X, String opcode, Map params, DPBudgetAccountant accountant) { - // ── 2. Parse DP parameters ────────────────────────────────────────── - double epsilon = parsePositiveDouble("epsilon"); - double delta = instOpcode.equals(OPCODE_GAUSSIAN) ? parsePositiveDouble("delta") : 0.0; - String query = _params.get("query"); + // ── 1. Parse DP parameters ────────────────────────────────────────── + double epsilon = parsePositiveDouble(opcode, params, "epsilon"); + double delta = opcode.equalsIgnoreCase(Opcodes.DP_GAUSSIAN.toString()) ? + parsePositiveDouble(opcode, params, "delta") : 0.0; + String query = params.get("query"); - // ── 3. Build the transformation matrix T (k x n) ──────────────────── + // ── 2. Build the transformation matrix T (k x n) ──────────────────── MatrixBlock T = buildTransform(query, X.getNumRows()); - // ── 4. Determine sensitivity (caller-supplied constant) ───────────── - double sensitivity = sensitivityOf(T); + // ── 3. Determine sensitivity (caller-supplied constant) ───────────── + double sensitivity = sensitivityOf(opcode, params, T); - // ── 5. Generate noise shaped like the release T %*% X (k x d) ─────── - MatrixBlock noiseBlock = generateNoise(T.getNumRows(), X.getNumColumns(), sensitivity, epsilon, delta); + // ── 4. Generate noise shaped like the release T %*% X (k x d) ─────── + MatrixBlock noiseBlock = generateNoise(opcode, T.getNumRows(), X.getNumColumns(), sensitivity, epsilon, delta); - // ── 6. Fuse T %*% X + noise into a single matrix multiply ─────────── + // ── 5. 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(); + // ── 6. Record release and enforce budget ──────────────────────────── 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); + return outBlock; } // ----------------------------------------------------------------------- @@ -275,8 +196,8 @@ private static MatrixBlock identity(int k) { * @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"); + private static double sensitivityOf(String opcode, Map params, MatrixBlock T) { + return parsePositiveDouble(opcode, params, "sensitivity"); } // ----------------------------------------------------------------------- @@ -291,11 +212,12 @@ private double sensitivityOf(MatrixBlock T) { * 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) { + private static MatrixBlock generateNoise(String opcode, int rows, int cols, double sensitivity, double epsilon, + double delta) { MatrixBlock noise; - if(instOpcode.equals(OPCODE_LAPLACE)) { + if(opcode.equalsIgnoreCase(Opcodes.DP_LAPLACE.toString())) { // Laplace mechanism // For a given epsilon, noise is drawn from the Laplace distribution at // scale b = sensitivity / epsilon @@ -312,20 +234,6 @@ private MatrixBlock generateNoise(int rows, int cols, double sensitivity, double return noise; } - // /** - // * Gaussian mechanism: calibrate sigma for (epsilon, delta)-DP. - // * Classical Gaussian mechanism calibration - // * - // * @param sensitivity L2 sensitivity - // * @param epsilon target epsilon - // * @param delta target delta - // * @return optimal sigma - // */ - // public static double getGaussianSigma(double sensitivity, double epsilon, double delta) { - // double sigma = sensitivity * Math.sqrt(2.0 * Math.log(1.25 / delta)) / epsilon; - // return sigma; - // } - /** * Compute the optimal sigma for the Analytic Gaussian Mechanism (Balle & Wang 2018). * Returns the smallest sigma such that the Gaussian mechanism is (epsilon, delta)-DP. @@ -408,19 +316,19 @@ private static MatrixBlock fillGaussianNoise(int rows, int cols, double sigma) { * * @throws DMLRuntimeException if the key is absent, unparseable, or non-positive */ - private double parsePositiveDouble(String key) { - String raw = _params.get(key); + private static double parsePositiveDouble(String opcode, Map params, String key) { + String raw = params.get(key); if(raw == null) - throw new DMLRuntimeException(instOpcode + ": parameter '" + key + "' is missing"); + throw new DMLRuntimeException(opcode + ": 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); + throw new DMLRuntimeException(opcode + ": parameter '" + key + "' is not a valid number: " + raw); } if(!(v > 0.0)) - throw new DMLRuntimeException(instOpcode + ": parameter '" + key + "' must be strictly positive, got " + v); + throw new DMLRuntimeException(opcode + ": parameter '" + key + "' must be strictly positive, got " + v); return v; } } diff --git a/src/main/java/org/apache/sysds/runtime/instructions/cp/ParameterizedBuiltinCPInstruction.java b/src/main/java/org/apache/sysds/runtime/instructions/cp/ParameterizedBuiltinCPInstruction.java index e53958ac4b8..c524dceff76 100644 --- a/src/main/java/org/apache/sysds/runtime/instructions/cp/ParameterizedBuiltinCPInstruction.java +++ b/src/main/java/org/apache/sysds/runtime/instructions/cp/ParameterizedBuiltinCPInstruction.java @@ -161,6 +161,18 @@ else if(opcode.equals(Opcodes.TRANSFORMAPPLY.toString()) || opcode.equals(Opcode else if(Opcodes.PARAMSERV.toString().equals(opcode)) { return new ParamservBuiltinCPInstruction(null, paramsMap, out, opcode, str); } + else if(opcode.equalsIgnoreCase(Opcodes.DP_GAUSSIAN.toString()) || opcode.equalsIgnoreCase(Opcodes.DP_LAPLACE.toString())) { + InstructionUtils.checkNumFields(parts, 5, 6); // laplace=5, gaussian=6 + if(!paramsMap.containsKey("query")) + throw new DMLRuntimeException(opcode + ": missing 'query'"); + if(!paramsMap.containsKey("sensitivity")) + throw new DMLRuntimeException(opcode + ": missing 'sensitivity'"); + if(!paramsMap.containsKey("epsilon")) + throw new DMLRuntimeException(opcode + ": missing 'epsilon'"); + if(opcode.equalsIgnoreCase(Opcodes.DP_GAUSSIAN.toString()) && !paramsMap.containsKey("delta")) + throw new DMLRuntimeException(opcode + ": missing 'delta'"); + return new ParameterizedBuiltinCPInstruction(null, paramsMap, out, opcode, str); + } else { throw new DMLRuntimeException("Unknown opcode (" + opcode + ") for ParameterizedBuiltin Instruction."); } @@ -459,6 +471,13 @@ else if(opcode.equals(Opcodes.NVLIST.toString())) { ec.setVariable(output.getName(), list); } + else if(opcode.equalsIgnoreCase(Opcodes.DP_GAUSSIAN.toString()) || opcode.equalsIgnoreCase(Opcodes.DP_LAPLACE.toString())) { + String target = params.get("target"); + MatrixBlock X = ec.getMatrixInput(target); + MatrixBlock outBlock = DPBuiltinOps.release(X, opcode, params, ec.getDPBudgetAccountant()); + ec.releaseMatrixInput(target); + ec.setMatrixOutput(output.getName(), outBlock); + } else { throw new DMLRuntimeException("Unknown opcode : " + opcode); } @@ -554,9 +573,15 @@ else if (opcode.equalsIgnoreCase(Opcodes.NVLIST.toString()) || opcode.equalsIgno CPOperand[] listOperands = names.stream().map(n -> ec.containsVariable(params.get(n)) ? new CPOperand(n, ec.getVariable(params.get(n))) : getStringLiteral(n)).toArray(CPOperand[]::new); - return Pair.of(output.getName(), + return Pair.of(output.getName(), new LineageItem(getOpcode(), LineageItemUtils.getLineage(ec, listOperands))); } + else if(opcode.equalsIgnoreCase(Opcodes.DP_GAUSSIAN.toString()) || opcode.equalsIgnoreCase(Opcodes.DP_LAPLACE.toString())) { + // dp_laplace/dp_gaussian draw fresh randomness and charge a privacy-budget side effect on every + // call, so a cached lineage-based reuse of a prior release would be unsound. + throw new DMLRuntimeException(opcode + ": lineage tracing not supported (draws fresh randomness " + + "and charges a privacy budget on every call)"); + } else { // NOTE: for now, we cannot have a generic fall through path, because the // data and value types of parmeters are not compiled into the instruction 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 index c2fd4bf4d9f..2d8e9929ab1 100644 --- a/src/main/java/org/apache/sysds/runtime/privacy/dp/DPBudgetAccountant.java +++ b/src/main/java/org/apache/sysds/runtime/privacy/dp/DPBudgetAccountant.java @@ -20,7 +20,7 @@ package org.apache.sysds.runtime.privacy.dp; import org.apache.sysds.runtime.DMLRuntimeException; -import org.apache.sysds.runtime.instructions.cp.DPBuiltinCPInstruction; +import org.apache.sysds.runtime.instructions.cp.DPBuiltinOps; /** * Session-scoped differential privacy budget accountant. @@ -60,7 +60,7 @@ * Not thread-safe. A single DML script executes instructions sequentially on one thread, so no synchronisation is * needed. * - * @see DPBuiltinCPInstruction + * @see DPBuiltinOps */ public class DPBudgetAccountant { @@ -178,7 +178,7 @@ public void compose(double epsilon, double delta, double sensitivity) { // Gaussian: accumulate Renyi divergence at each order, then convert. _hasGaussianReleases = true; for(int i = 0; i < ORDERS.length; i++) { - double sigma = DPBuiltinCPInstruction.computeGaussianSigma(sensitivity, epsilon, delta); + double sigma = DPBuiltinOps.computeGaussianSigma(sensitivity, epsilon, delta); _rdpSum[i] += rdpGaussian(ORDERS[i], sensitivity, sigma); } } diff --git a/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java b/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinOpsTest.java similarity index 88% rename from src/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java rename to src/test/java/org/apache/sysds/test/component/cp/DPBuiltinOpsTest.java index b07e2201dc9..74ecec55e23 100755 --- a/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinCPInstructionTest.java +++ b/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinOpsTest.java @@ -19,8 +19,10 @@ package org.apache.sysds.test.component.cp; +import org.apache.sysds.common.Opcodes; import org.apache.sysds.runtime.DMLRuntimeException; -import org.apache.sysds.runtime.instructions.cp.DPBuiltinCPInstruction; +import org.apache.sysds.runtime.instructions.cp.DPBuiltinOps; +import org.apache.sysds.runtime.instructions.cp.ParameterizedBuiltinCPInstruction; import org.apache.sysds.runtime.matrix.data.MatrixBlock; import org.apache.sysds.runtime.privacy.dp.DPBudgetAccountant; @@ -30,24 +32,23 @@ import org.junit.Assert; /** - * Tests for DPBuiltinCPInstruction and DPBudgetAccountant. + * Tests for DPBuiltinOps, ParameterizedBuiltinCPInstruction (dp_laplace/dp_gaussian), and DPBudgetAccountant. * - * The tests are grouped into four levels: - * - Unit tests on DPBudgetAccountant - verify composition, conversion, and + * The tests in this class are grouped into three levels: + * 1. 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 + * 2. 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). - * - DPBuiltinCPInstruction structural tests - exercise parseInstruction()'s required-parameter - * validation. - * - 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 + * 3. ParameterizedBuiltinCPInstruction structural tests - exercise parseInstruction()'s + * required-parameter validation for dp_laplace/dp_gaussian. + * A fourth level, DML integration tests that run complete scripts end-to-end via the existing + * AutomatedTestBase machinery, requires a built SystemDS jar and lives in a companion class, * {@link org.apache.sysds.test.functions.privacy.dp.DPBuiltinDMLTest}. */ -public class DPBuiltinCPInstructionTest { +public class DPBuiltinOpsTest { - private static final double EPS = 1e-9; + private static final double ASSERT_TOLERANCE = 1e-9; // ======================================================================= // 1. DPBudgetAccountant unit tests @@ -194,7 +195,7 @@ public void testGaussianSensitivityCancelsInRDP() { acc1.compose(0.5, 1e-5, 1.0); // sensitivity = 1 acc2.compose(0.5, 1e-5, 100.0); // sensitivity = 100, same (epsilon,delta) Assert.assertEquals("Gaussian RDP cost must be independent of sensitivity when (epsilon,delta) are fixed", - acc1.totalEpsilonSpent(), acc2.totalEpsilonSpent(), EPS); + acc1.totalEpsilonSpent(), acc2.totalEpsilonSpent(), ASSERT_TOLERANCE); } @Test @@ -251,24 +252,24 @@ public void testGaussianNoiseDistribution() throws ReflectiveOperationException } // ======================================================================= - // 3. DPBuiltinCPInstruction structural tests + // 3. ParameterizedBuiltinCPInstruction structural tests (dp_laplace/dp_gaussian) // ======================================================================= @Test public void testParseInstructionValidLaplace() { - DPBuiltinCPInstruction inst = DPBuiltinCPInstruction + ParameterizedBuiltinCPInstruction inst = ParameterizedBuiltinCPInstruction .parseInstruction("CP°dp_laplace°target=mVar1°query=colMeans°sensitivity=1.0°epsilon=0.5°_mVar2·MATRIX·FP64"); - Assert.assertEquals(DPBuiltinCPInstruction.OPCODE_LAPLACE, inst.getOpcode()); - Assert.assertEquals("mVar1", inst.input1.getName()); + Assert.assertEquals(Opcodes.DP_LAPLACE.toString(), inst.getOpcode()); + Assert.assertEquals("mVar1", inst.getParam("target")); Assert.assertEquals("_mVar2", inst.getOutput().getName()); } @Test public void testParseInstructionValidGaussian() { - DPBuiltinCPInstruction inst = DPBuiltinCPInstruction.parseInstruction( + ParameterizedBuiltinCPInstruction inst = ParameterizedBuiltinCPInstruction.parseInstruction( "CP°dp_gaussian°target=mVar1°query=colMeans°sensitivity=1.0°epsilon=0.5°delta=1e-5°_mVar2·MATRIX·FP64"); - Assert.assertEquals(DPBuiltinCPInstruction.OPCODE_GAUSSIAN, inst.getOpcode()); - Assert.assertEquals("mVar1", inst.input1.getName()); + Assert.assertEquals(Opcodes.DP_GAUSSIAN.toString(), inst.getOpcode()); + Assert.assertEquals("mVar1", inst.getParam("target")); Assert.assertEquals("_mVar2", inst.getOutput().getName()); } @@ -289,7 +290,7 @@ public void testParseInstructionMissingRequiredKeysThrows() { private static void assertParseInstructionRejects(String instStr) { try { - DPBuiltinCPInstruction.parseInstruction(instStr); + ParameterizedBuiltinCPInstruction.parseInstruction(instStr); Assert.fail("Expected DMLRuntimeException for instruction: " + instStr); } catch(DMLRuntimeException e) { @@ -303,7 +304,7 @@ private static void assertParseInstructionRejects(String instStr) { /** Sample n Laplace(0, scale) values via the fillLaplaceNoise method. */ private static double[] sampleLaplace(int n, double scale) throws ReflectiveOperationException { - Method m = DPBuiltinCPInstruction.class.getDeclaredMethod("fillLaplaceNoise", int.class, int.class, double.class); + Method m = DPBuiltinOps.class.getDeclaredMethod("fillLaplaceNoise", int.class, int.class, double.class); m.setAccessible(true); MatrixBlock block = (MatrixBlock) m.invoke(null, n, 1, scale); @@ -315,7 +316,7 @@ private static double[] sampleLaplace(int n, double scale) throws ReflectiveOper /** Sample n N(0, sigma^2) values via the production fillGaussianNoise method. */ private static double[] sampleGaussian(int n, double sigma) throws ReflectiveOperationException { - Method m = DPBuiltinCPInstruction.class.getDeclaredMethod("fillGaussianNoise", int.class, int.class, + Method m = DPBuiltinOps.class.getDeclaredMethod("fillGaussianNoise", int.class, int.class, double.class); m.setAccessible(true); MatrixBlock block = (MatrixBlock) m.invoke(null, n, 1, sigma); From d021e92654b2c1d04a18cab42470bdd3da58e846 Mon Sep 17 00:00:00 2001 From: Maya Anderson Date: Tue, 4 Aug 2026 17:26:12 +0200 Subject: [PATCH 40/43] Comment readability --- .../apache/sysds/runtime/privacy/dp/DPBudgetAccountant.java | 6 +++--- .../apache/sysds/test/component/cp/DPBuiltinOpsTest.java | 6 ++++-- 2 files changed, 7 insertions(+), 5 deletions(-) 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 index 2d8e9929ab1..e98cf635a49 100644 --- a/src/main/java/org/apache/sysds/runtime/privacy/dp/DPBudgetAccountant.java +++ b/src/main/java/org/apache/sysds/runtime/privacy/dp/DPBudgetAccountant.java @@ -46,8 +46,8 @@ * Renyi orders tracked (Gaussian path) alpha in {2, 4, 8, 16, 32, 64, 128, 256, 512, 1024}. At query time the minimum * converted epsilon across all orders is taken as the tightest available bound. * - * Gaussian RDP divergence For the Gaussian mechanism with noise scale sigma and L2 sensitivity delta_f: - * D_alpha = alpha * delta_f^2 / (2*sigma^2) sigma + * Gaussian RDP divergence For the Gaussian mechanism with noise scale sigma and L2 sensitivity: + * D_alpha = alpha * sensitivity^2 / (2*sigma^2) * is back-derived from the caller's (epsilon, delta) via the standard calibration formula (see {@link #gaussianSigma}). Note that * sensitivity cancels in the final expression, so the RDP cost depends only on the (epsilon, delta) parameters. * @@ -233,7 +233,7 @@ public int releaseCount() { /** * Renyi divergence of order alpha for the Gaussian mechanism (Mironov 2017, Proposition 3, example 2): - * D_alpha = alpha * delta_f^2 / (2 * sigma^2) + * D_alpha = alpha * sensitivity^2 / (2 * sigma^2) */ private static double rdpGaussian(double alpha, double sensitivity, double sigma) { return alpha * (sensitivity * sensitivity) / (2.0 * sigma * sigma); diff --git a/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinOpsTest.java b/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinOpsTest.java index 74ecec55e23..a695d04eebc 100755 --- a/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinOpsTest.java +++ b/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinOpsTest.java @@ -186,8 +186,10 @@ public void testMixedCompositionExceedsEitherAlone() { @Test public void testGaussianSensitivityCancelsInRDP() { - // For the Gaussian mechanism: sigma = delta_f*sqrt(2*ln(1.25/delta))/epsilon, so - // D_alpha = alpha*delta_f^2/(2*sigma^2) = alpha*epsilon^2/(4*ln(1.25/delta)). + // For the Gaussian mechanism, since sigma is calibrated from (epsilon, delta, sensitivity): + // sigma = sensitivity*sqrt(2*ln(1.25/delta))/epsilon, so + // the Renyi divergence of order alpha, i.e. the cost of this release is: + // D_alpha = alpha*sensitivity^2/(2*sigma^2) = alpha*epsilon^2/(4*ln(1.25/delta)). // Sensitivity cancels. Two accountants with the same (epsilon,delta) but // different sensitivity must report identical totalEpsilonSpent(). DPBudgetAccountant acc1 = new DPBudgetAccountant(100.0, 1e-5); From 8b2e967f5259103e2a260ff42ee7facf055c9b3b Mon Sep 17 00:00:00 2001 From: Maya Anderson Date: Tue, 4 Aug 2026 20:25:05 +0200 Subject: [PATCH 41/43] =?UTF-8?q?Return=20DPBuiltinCPInstruction,=20but=20?= =?UTF-8?q?now=20it=20extends=20ParameterizedBuiltinCPInstruction,=20ownin?= =?UTF-8?q?g=20parse=20validation,=20processInstruction(),=20and=20the=20l?= =?UTF-8?q?ineage-refusal=20=E2=80=94=20all=20DP=20instruction-level=20con?= =?UTF-8?q?cerns=20now=20live=20in=20one=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cp/DPBuiltinCPInstruction.java | 81 +++++++++++++++++++ .../cp/ParameterizedBuiltinCPInstruction.java | 24 +----- .../test/component/cp/DPBuiltinOpsTest.java | 58 +++++++++++-- 3 files changed, 132 insertions(+), 31 deletions(-) create mode 100644 src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinCPInstruction.java 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 100644 index 00000000000..f443943c1ce --- /dev/null +++ b/src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinCPInstruction.java @@ -0,0 +1,81 @@ +/* + * 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 java.util.LinkedHashMap; + +import org.apache.commons.lang3.tuple.Pair; +import org.apache.sysds.common.Opcodes; +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.lineage.LineageItem; +import org.apache.sysds.runtime.matrix.data.MatrixBlock; +import org.apache.sysds.runtime.matrix.operators.Operator; + +/** + * CP instruction for the {@code dp_laplace}/{@code dp_gaussian} opcodes. Subclasses + * {@link ParameterizedBuiltinCPInstruction} the same way {@link ParamservBuiltinCPInstruction} does for + * {@code paramserv}: parse-time validation, execution, and lineage handling are owned here rather than as + * inline opcode branches in the shared class, since the DP release charges a privacy budget and draws fresh + * randomness on every call - side effects that don't fit the shared class's other (pure, replayable) opcodes. + * + * The DP math itself (transform construction, noise generation, sigma calibration) lives in + * {@link DPBuiltinOps}, which this class calls into. + */ +public class DPBuiltinCPInstruction extends ParameterizedBuiltinCPInstruction { + + public DPBuiltinCPInstruction(Operator op, LinkedHashMap paramsMap, CPOperand out, + String opcode, String istr) { + super(op, paramsMap, out, opcode, istr); + } + + static DPBuiltinCPInstruction parse(String[] parts, LinkedHashMap paramsMap, CPOperand out, + String opcode, String istr) { + InstructionUtils.checkNumFields(parts, 5, 6); // laplace=5, gaussian=6 + if(!paramsMap.containsKey("query")) + throw new DMLRuntimeException(opcode + ": missing 'query'"); + if(!paramsMap.containsKey("sensitivity")) + throw new DMLRuntimeException(opcode + ": missing 'sensitivity'"); + if(!paramsMap.containsKey("epsilon")) + throw new DMLRuntimeException(opcode + ": missing 'epsilon'"); + if(opcode.equalsIgnoreCase(Opcodes.DP_GAUSSIAN.toString()) && !paramsMap.containsKey("delta")) + throw new DMLRuntimeException(opcode + ": missing 'delta'"); + return new DPBuiltinCPInstruction(null, paramsMap, out, opcode, istr); + } + + @Override + public void processInstruction(ExecutionContext ec) { + String opcode = getOpcode(); + String target = params.get("target"); + MatrixBlock X = ec.getMatrixInput(target); + MatrixBlock outBlock = DPBuiltinOps.release(X, opcode, params, ec.getDPBudgetAccountant()); + ec.releaseMatrixInput(target); + ec.setMatrixOutput(output.getName(), outBlock); + } + + @Override + public Pair getLineageItem(ExecutionContext ec) { + // dp_laplace/dp_gaussian draw fresh randomness and charge a privacy-budget side effect on every + // call, so a cached lineage-based reuse of a prior release would be unsound. + throw new DMLRuntimeException(getOpcode() + ": lineage tracing not supported (draws fresh randomness " + + "and charges a privacy budget on every call)"); + } +} diff --git a/src/main/java/org/apache/sysds/runtime/instructions/cp/ParameterizedBuiltinCPInstruction.java b/src/main/java/org/apache/sysds/runtime/instructions/cp/ParameterizedBuiltinCPInstruction.java index c524dceff76..04282a5ab12 100644 --- a/src/main/java/org/apache/sysds/runtime/instructions/cp/ParameterizedBuiltinCPInstruction.java +++ b/src/main/java/org/apache/sysds/runtime/instructions/cp/ParameterizedBuiltinCPInstruction.java @@ -162,16 +162,7 @@ else if(Opcodes.PARAMSERV.toString().equals(opcode)) { return new ParamservBuiltinCPInstruction(null, paramsMap, out, opcode, str); } else if(opcode.equalsIgnoreCase(Opcodes.DP_GAUSSIAN.toString()) || opcode.equalsIgnoreCase(Opcodes.DP_LAPLACE.toString())) { - InstructionUtils.checkNumFields(parts, 5, 6); // laplace=5, gaussian=6 - if(!paramsMap.containsKey("query")) - throw new DMLRuntimeException(opcode + ": missing 'query'"); - if(!paramsMap.containsKey("sensitivity")) - throw new DMLRuntimeException(opcode + ": missing 'sensitivity'"); - if(!paramsMap.containsKey("epsilon")) - throw new DMLRuntimeException(opcode + ": missing 'epsilon'"); - if(opcode.equalsIgnoreCase(Opcodes.DP_GAUSSIAN.toString()) && !paramsMap.containsKey("delta")) - throw new DMLRuntimeException(opcode + ": missing 'delta'"); - return new ParameterizedBuiltinCPInstruction(null, paramsMap, out, opcode, str); + return DPBuiltinCPInstruction.parse(parts, paramsMap, out, opcode, str); } else { throw new DMLRuntimeException("Unknown opcode (" + opcode + ") for ParameterizedBuiltin Instruction."); @@ -471,13 +462,6 @@ else if(opcode.equals(Opcodes.NVLIST.toString())) { ec.setVariable(output.getName(), list); } - else if(opcode.equalsIgnoreCase(Opcodes.DP_GAUSSIAN.toString()) || opcode.equalsIgnoreCase(Opcodes.DP_LAPLACE.toString())) { - String target = params.get("target"); - MatrixBlock X = ec.getMatrixInput(target); - MatrixBlock outBlock = DPBuiltinOps.release(X, opcode, params, ec.getDPBudgetAccountant()); - ec.releaseMatrixInput(target); - ec.setMatrixOutput(output.getName(), outBlock); - } else { throw new DMLRuntimeException("Unknown opcode : " + opcode); } @@ -576,12 +560,6 @@ else if (opcode.equalsIgnoreCase(Opcodes.NVLIST.toString()) || opcode.equalsIgno return Pair.of(output.getName(), new LineageItem(getOpcode(), LineageItemUtils.getLineage(ec, listOperands))); } - else if(opcode.equalsIgnoreCase(Opcodes.DP_GAUSSIAN.toString()) || opcode.equalsIgnoreCase(Opcodes.DP_LAPLACE.toString())) { - // dp_laplace/dp_gaussian draw fresh randomness and charge a privacy-budget side effect on every - // call, so a cached lineage-based reuse of a prior release would be unsound. - throw new DMLRuntimeException(opcode + ": lineage tracing not supported (draws fresh randomness " - + "and charges a privacy budget on every call)"); - } else { // NOTE: for now, we cannot have a generic fall through path, because the // data and value types of parmeters are not compiled into the instruction diff --git a/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinOpsTest.java b/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinOpsTest.java index a695d04eebc..e30ae69a0fc 100755 --- a/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinOpsTest.java +++ b/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinOpsTest.java @@ -21,6 +21,7 @@ import org.apache.sysds.common.Opcodes; import org.apache.sysds.runtime.DMLRuntimeException; +import org.apache.sysds.runtime.instructions.cp.DPBuiltinCPInstruction; import org.apache.sysds.runtime.instructions.cp.DPBuiltinOps; import org.apache.sysds.runtime.instructions.cp.ParameterizedBuiltinCPInstruction; import org.apache.sysds.runtime.matrix.data.MatrixBlock; @@ -32,7 +33,7 @@ import org.junit.Assert; /** - * Tests for DPBuiltinOps, ParameterizedBuiltinCPInstruction (dp_laplace/dp_gaussian), and DPBudgetAccountant. + * Tests for DPBuiltinOps, DPBuiltinCPInstruction (dp_laplace/dp_gaussian), and DPBudgetAccountant. * * The tests in this class are grouped into three levels: * 1. Unit tests on DPBudgetAccountant - verify composition, conversion, and @@ -40,8 +41,9 @@ * 2. 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). - * 3. ParameterizedBuiltinCPInstruction structural tests - exercise parseInstruction()'s - * required-parameter validation for dp_laplace/dp_gaussian. + * 3. DPBuiltinCPInstruction structural tests - exercise parse validation, the + * ParameterizedBuiltinCPInstruction.parseInstruction() -> DPBuiltinCPInstruction dispatch, and the + * lineage-tracing refusal. * A fourth level, DML integration tests that run complete scripts end-to-end via the existing * AutomatedTestBase machinery, requires a built SystemDS jar and lives in a companion class, * {@link org.apache.sysds.test.functions.privacy.dp.DPBuiltinDMLTest}. @@ -254,13 +256,13 @@ public void testGaussianNoiseDistribution() throws ReflectiveOperationException } // ======================================================================= - // 3. ParameterizedBuiltinCPInstruction structural tests (dp_laplace/dp_gaussian) + // 3. DPBuiltinCPInstruction structural tests (dp_laplace/dp_gaussian) // ======================================================================= @Test public void testParseInstructionValidLaplace() { - ParameterizedBuiltinCPInstruction inst = ParameterizedBuiltinCPInstruction - .parseInstruction("CP°dp_laplace°target=mVar1°query=colMeans°sensitivity=1.0°epsilon=0.5°_mVar2·MATRIX·FP64"); + DPBuiltinCPInstruction inst = parseDP( + "CP°dp_laplace°target=mVar1°query=colMeans°sensitivity=1.0°epsilon=0.5°_mVar2·MATRIX·FP64"); Assert.assertEquals(Opcodes.DP_LAPLACE.toString(), inst.getOpcode()); Assert.assertEquals("mVar1", inst.getParam("target")); Assert.assertEquals("_mVar2", inst.getOutput().getName()); @@ -268,7 +270,7 @@ public void testParseInstructionValidLaplace() { @Test public void testParseInstructionValidGaussian() { - ParameterizedBuiltinCPInstruction inst = ParameterizedBuiltinCPInstruction.parseInstruction( + DPBuiltinCPInstruction inst = parseDP( "CP°dp_gaussian°target=mVar1°query=colMeans°sensitivity=1.0°epsilon=0.5°delta=1e-5°_mVar2·MATRIX·FP64"); Assert.assertEquals(Opcodes.DP_GAUSSIAN.toString(), inst.getOpcode()); Assert.assertEquals("mVar1", inst.getParam("target")); @@ -278,7 +280,7 @@ public void testParseInstructionValidGaussian() { @Test public void testParseInstructionMissingRequiredKeysThrows() { // Each string below keeps the field COUNT that checkNumFields expects (5 for laplace, - // 6 for gaussian), but renames one required key so parseInstruction's own + // 6 for gaussian), but renames one required key so DPBuiltinCPInstruction.parse()'s own // containsKey(...) checks - not checkNumFields - are what reject it. assertParseInstructionRejects( "CP°dp_laplace°target=mVar1°qry=colMeans°sensitivity=1.0°epsilon=0.5°_mVar2·MATRIX·FP64"); // missing query @@ -290,6 +292,36 @@ public void testParseInstructionMissingRequiredKeysThrows() { + "notdelta=1e-5°_mVar2·MATRIX·FP64"); // missing delta (gaussian only) } + @Test + public void testParseInstructionDispatchesToDPBuiltinCPInstruction() { + // ParameterizedBuiltinCPInstruction.parseInstruction() is the production entry point + // (called from CPInstructionParser); confirm dp_laplace/dp_gaussian resolve to the + // DPBuiltinCPInstruction subclass rather than the base class, i.e. that + // ParameterizedBuiltinCPInstruction's DP_GAUSSIAN/DP_LAPLACE branch is wired correctly. + ParameterizedBuiltinCPInstruction laplace = ParameterizedBuiltinCPInstruction + .parseInstruction("CP°dp_laplace°target=mVar1°query=colMeans°sensitivity=1.0°epsilon=0.5°_mVar2·MATRIX·FP64"); + Assert.assertTrue("dp_laplace must parse to a DPBuiltinCPInstruction", laplace instanceof DPBuiltinCPInstruction); + + ParameterizedBuiltinCPInstruction gaussian = ParameterizedBuiltinCPInstruction.parseInstruction( + "CP°dp_gaussian°target=mVar1°query=colMeans°sensitivity=1.0°epsilon=0.5°delta=1e-5°_mVar2·MATRIX·FP64"); + Assert.assertTrue("dp_gaussian must parse to a DPBuiltinCPInstruction", gaussian instanceof DPBuiltinCPInstruction); + } + + @Test + public void testLineageTracingThrowsForBothMechanisms() { + // DPBuiltinCPInstruction.getLineageItem() must refuse to trace: dp_laplace/dp_gaussian draw + // fresh randomness and charge a privacy-budget side effect on every call, so a cached + // lineage-based reuse of a prior release would be unsound. + assertLineageTracingRejected(parseDP( + "CP°dp_laplace°target=mVar1°query=colMeans°sensitivity=1.0°epsilon=0.5°_mVar2·MATRIX·FP64")); + assertLineageTracingRejected(parseDP( + "CP°dp_gaussian°target=mVar1°query=colMeans°sensitivity=1.0°epsilon=0.5°delta=1e-5°_mVar2·MATRIX·FP64")); + } + + private static DPBuiltinCPInstruction parseDP(String instStr) { + return (DPBuiltinCPInstruction) ParameterizedBuiltinCPInstruction.parseInstruction(instStr); + } + private static void assertParseInstructionRejects(String instStr) { try { ParameterizedBuiltinCPInstruction.parseInstruction(instStr); @@ -300,6 +332,16 @@ private static void assertParseInstructionRejects(String instStr) { } } + private static void assertLineageTracingRejected(DPBuiltinCPInstruction inst) { + try { + inst.getLineageItem(null); + Assert.fail("Expected DMLRuntimeException for lineage tracing of opcode: " + inst.getOpcode()); + } + catch(DMLRuntimeException e) { + // expected + } + } + // ----------------------------------------------------------------------- // Helpers for noise distribution tests // ----------------------------------------------------------------------- From 20a640f406481c3a69c9777cfc1c87c02285c160 Mon Sep 17 00:00:00 2001 From: Maya Anderson Date: Wed, 5 Aug 2026 17:20:08 +0200 Subject: [PATCH 42/43] Comment cleanups after review fixes --- .../sysds/runtime/instructions/CPInstructionParser.java | 2 +- .../apache/sysds/runtime/instructions/cp/CPInstruction.java | 5 +---- .../runtime/instructions/cp/DPBuiltinCPInstruction.java | 6 +++--- .../apache/sysds/runtime/instructions/cp/DPBuiltinOps.java | 2 +- .../apache/sysds/runtime/privacy/dp/DPBudgetAccountant.java | 5 ++--- .../apache/sysds/test/component/cp/DPBuiltinOpsTest.java | 6 +++--- 6 files changed, 11 insertions(+), 15 deletions(-) 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..599f1dfafed 100644 --- a/src/main/java/org/apache/sysds/runtime/instructions/CPInstructionParser.java +++ b/src/main/java/org/apache/sysds/runtime/instructions/CPInstructionParser.java @@ -226,7 +226,7 @@ public static CPInstruction parseSingleInstruction ( InstructionType cptype, Str case EINSUM: return EinsumCPInstruction.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..046f09df8d7 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 @@ -45,10 +45,7 @@ public enum CPType { Builtin, Reorg, Variable, FCall, Append, Rand, QSort, QPick, Local, MatrixIndexing, MMTSJ, PMMJ, MMChain, Reshape, Partition, Compression, DeCompression, SpoofFused, StringInit, CentralMoment, Covariance, UaggOuterChain, Dnn, Sql, Prefetch, Broadcast, TrigRemote, - EvictLineageCache, EINSUM, - NoOp, - Union, - QuantizeCompression + EvictLineageCache, EINSUM, NoOp, Union, QuantizeCompression } 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 index f443943c1ce..79f6ce52efe 100644 --- a/src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinCPInstruction.java +++ b/src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinCPInstruction.java @@ -31,9 +31,9 @@ import org.apache.sysds.runtime.matrix.operators.Operator; /** - * CP instruction for the {@code dp_laplace}/{@code dp_gaussian} opcodes. Subclasses - * {@link ParameterizedBuiltinCPInstruction} the same way {@link ParamservBuiltinCPInstruction} does for - * {@code paramserv}: parse-time validation, execution, and lineage handling are owned here rather than as + * CP instruction for the dp_laplace/dp_gaussian opcodes. Subclasses + * {@link ParameterizedBuiltinCPInstruction}: + * parse-time validation, execution, and lineage handling are owned here rather than as * inline opcode branches in the shared class, since the DP release charges a privacy budget and draws fresh * randomness on every call - side effects that don't fit the shared class's other (pure, replayable) opcodes. * diff --git a/src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinOps.java b/src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinOps.java index 2930f92c2e6..33d4580c15b 100755 --- a/src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinOps.java +++ b/src/main/java/org/apache/sysds/runtime/instructions/cp/DPBuiltinOps.java @@ -36,7 +36,7 @@ /** * Differential-privacy release of a linear query over the original matrix, invoked from - * {@link ParameterizedBuiltinCPInstruction} for the {@code dp_laplace}/{@code dp_gaussian} opcodes. + * {@link ParameterizedBuiltinCPInstruction} for the dp_laplace/dp_gaussian opcodes. * * DML syntax (raw-matrix form): * result = dp_laplace(X, query="colMeans", sensitivity=1.0, epsilon=0.5) 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 index e98cf635a49..44bcbda301a 100644 --- a/src/main/java/org/apache/sysds/runtime/privacy/dp/DPBudgetAccountant.java +++ b/src/main/java/org/apache/sysds/runtime/privacy/dp/DPBudgetAccountant.java @@ -113,9 +113,8 @@ public class DPBudgetAccountant { /** * Creates an accountant with the given global budget. * - * Typical usage: the DML script sets the budget once at the top (future work: a - * dp_set_budget(epsilon, delta) built-in), or the accountant is created with defaults and the budget is - * checked after each release. + * Typical usage: the DML script sets the budget once at the top (the 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 epsilon budget for the script execution (must be > 0) * @param delta delta used for the Gaussian RDP-to-(epsilon,delta) conversion (must be in (0,1)) diff --git a/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinOpsTest.java b/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinOpsTest.java index e30ae69a0fc..49a1f74a6bf 100755 --- a/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinOpsTest.java +++ b/src/test/java/org/apache/sysds/test/component/cp/DPBuiltinOpsTest.java @@ -42,7 +42,7 @@ * Gaussian mechanisms have statistically correct means and variances * (Kolmogorov-Smirnov style sanity checks). * 3. DPBuiltinCPInstruction structural tests - exercise parse validation, the - * ParameterizedBuiltinCPInstruction.parseInstruction() -> DPBuiltinCPInstruction dispatch, and the + * ParameterizedBuiltinCPInstruction.parseInstruction() -> DPBuiltinCPInstruction dispatch, and the * lineage-tracing refusal. * A fourth level, DML integration tests that run complete scripts end-to-end via the existing * AutomatedTestBase machinery, requires a built SystemDS jar and lives in a companion class, @@ -101,8 +101,8 @@ public void testBudgetExhaustionThrows() { public void testGaussianTighterThanLaplaceForSameEpsilon() { // For the same nominal (epsilon, delta), Gaussian uses RDP composition which // is tighter than Laplace with basic composition. After 5 releases: - // Laplace (basic, worst-case): 5epsilon - // Gaussian (RDP) : something < 5epsilon + // Laplace (basic, worst-case): 5*epsilon + // Gaussian (RDP) : something < 5*epsilon double eps = 0.5; double delta = 1e-5; From d47883b870bb72575ac23bdd358ada5d53d413a0 Mon Sep 17 00:00:00 2001 From: Maya Anderson Date: Wed, 5 Aug 2026 18:47:55 +0200 Subject: [PATCH 43/43] Move DP benchmark under dp folder and add a README --- benchmark/dp/README.md | 67 +++++++++++++++++++ benchmark/dp/scripts/benchmark_utilities.py | 30 +++++++++ benchmark/{ => dp}/scripts/collect_results.py | 10 +-- benchmark/{ => dp}/scripts/eval.dml | 0 benchmark/{ => dp}/scripts/fedavg_dp.dml | 0 benchmark/{ => dp}/scripts/plot.py | 9 ++- benchmark/{ => dp}/scripts/prepare_data.py | 3 +- benchmark/dp/scripts/requirements.txt | 10 +++ benchmark/{ => dp}/scripts/run_benchmark.sh | 11 +-- benchmark/{ => dp}/scripts/run_sweep.sh | 0 benchmark/{ => dp}/scripts/start_workers.sh | 0 benchmark/{ => dp}/scripts/stop_workers.sh | 0 12 files changed, 124 insertions(+), 16 deletions(-) create mode 100644 benchmark/dp/README.md create mode 100644 benchmark/dp/scripts/benchmark_utilities.py rename benchmark/{ => dp}/scripts/collect_results.py (90%) rename benchmark/{ => dp}/scripts/eval.dml (100%) rename benchmark/{ => dp}/scripts/fedavg_dp.dml (100%) rename benchmark/{ => dp}/scripts/plot.py (96%) rename benchmark/{ => dp}/scripts/prepare_data.py (99%) create mode 100644 benchmark/dp/scripts/requirements.txt rename benchmark/{ => dp}/scripts/run_benchmark.sh (78%) rename benchmark/{ => dp}/scripts/run_sweep.sh (100%) rename benchmark/{ => dp}/scripts/start_workers.sh (100%) rename benchmark/{ => dp}/scripts/stop_workers.sh (100%) diff --git a/benchmark/dp/README.md b/benchmark/dp/README.md new file mode 100644 index 00000000000..95e9338051d --- /dev/null +++ b/benchmark/dp/README.md @@ -0,0 +1,67 @@ + + +# DP-FedAvg Benchmark + +Benchmarks the `dp_gaussian` built-in by sweeping the privacy budget ε for +federated logistic regression (DP-FedAvg) on the UCI Adult dataset, and +plotting the accuracy/privacy trade-off. + +## Setup + +Run these commands from the repository root. They create a virtual +environment named `python_venv` and install the Python dependencies listed +in [scripts/requirements.txt](scripts/requirements.txt): + +```bash +python3 -m venv benchmark/dp/scripts/python_venv +source benchmark/dp/scripts/python_venv/bin/activate +pip install -r benchmark/dp/scripts/requirements.txt +``` + +Build SystemDS before running the benchmark, if you haven't already: + +```bash +mvn clean package -DskipTests +``` + +## Running + +With `python_venv` activated, run the benchmark from the repository root: + +```bash +bash benchmark/dp/scripts/run_benchmark.sh +``` + +This prepares the dataset, starts the federated workers, sweeps +epsilon over {0.5, 1, 4, 8} plus a non-private baseline, stops the workers, and +generates the plots. + +## Outputs + +- Prepared dataset and per-worker federated shards: [benchmark/dp/data/](data/) +- Trained models, accuracy logs, and results table: [benchmark/dp/results/](results/) +- Accuracy vs. epsilon plot: [benchmark/dp/results/accuracy_vs_epsilon.png](results/accuracy_vs_epsilon.png) +- Utility cost of privacy plot: [benchmark/dp/results/privacy_cost.png](results/privacy_cost.png) + +Deactivate the virtual environment when done: + +```bash +deactivate +``` diff --git a/benchmark/dp/scripts/benchmark_utilities.py b/benchmark/dp/scripts/benchmark_utilities.py new file mode 100644 index 00000000000..989f611218f --- /dev/null +++ b/benchmark/dp/scripts/benchmark_utilities.py @@ -0,0 +1,30 @@ +#------------------------------------------------------------- +# +# 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. +# +#------------------------------------------------------------- + + +""" +Common constants +""" +import pathlib + +RESULTS_DIR = pathlib.Path("benchmark", "dp", "results") +DATA_DIR = pathlib.Path("benchmark", "dp", "data") + diff --git a/benchmark/scripts/collect_results.py b/benchmark/dp/scripts/collect_results.py similarity index 90% rename from benchmark/scripts/collect_results.py rename to benchmark/dp/scripts/collect_results.py index b874876d871..e7ee9ba356f 100644 --- a/benchmark/scripts/collect_results.py +++ b/benchmark/dp/scripts/collect_results.py @@ -24,9 +24,9 @@ Output columns: label, epsilon, private, accuracy """ -import pathlib, csv, re +import pathlib, csv -RESULTS = pathlib.Path("benchmark/results") +from benchmark_utilities import RESULTS_DIR rows = [] @@ -36,21 +36,21 @@ def parse_acc(path: pathlib.Path) -> float: return float(txt) # Non-private baseline. -baseline_path = RESULTS / "acc_baseline.txt" +baseline_path = RESULTS_DIR / "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" + p = RESULTS_DIR / f"acc_eps_{eps}.txt" if p.exists(): rows.append(dict(label=f"epsilon={eps}", epsilon=eps, private=1, accuracy=parse_acc(p))) else: print(f"Warning: {p} not found - skipping") -out = RESULTS / "results.csv" +out = RESULTS_DIR / "results.csv" with open(out, "w", newline="") as f: w = csv.DictWriter(f, fieldnames=["label","epsilon","private","accuracy"]) w.writeheader() diff --git a/benchmark/scripts/eval.dml b/benchmark/dp/scripts/eval.dml similarity index 100% rename from benchmark/scripts/eval.dml rename to benchmark/dp/scripts/eval.dml diff --git a/benchmark/scripts/fedavg_dp.dml b/benchmark/dp/scripts/fedavg_dp.dml similarity index 100% rename from benchmark/scripts/fedavg_dp.dml rename to benchmark/dp/scripts/fedavg_dp.dml diff --git a/benchmark/scripts/plot.py b/benchmark/dp/scripts/plot.py similarity index 96% rename from benchmark/scripts/plot.py rename to benchmark/dp/scripts/plot.py index 7b160d065de..1c4117bb677 100644 --- a/benchmark/scripts/plot.py +++ b/benchmark/dp/scripts/plot.py @@ -29,18 +29,17 @@ 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") +from benchmark_utilities import RESULTS_DIR # ── Load ────────────────────────────────────────────────────────────────── rows = [] -with open(RESULTS / "results.csv") as f: +with open(RESULTS_DIR / "results.csv") as f: for r in csv.DictReader(f): rows.append({ "label": r["label"], @@ -83,7 +82,7 @@ ax.grid(True, which="both", linestyle=":", alpha=0.5) plt.tight_layout() -out1 = RESULTS / "accuracy_vs_epsilon.png" +out1 = RESULTS_DIR / "accuracy_vs_epsilon.png" fig.savefig(out1, dpi=150) print(f"Saved {out1}") plt.close() @@ -119,7 +118,7 @@ ax.grid(True, axis="y", linestyle=":", alpha=0.5) plt.tight_layout() -out2 = RESULTS / "privacy_cost.png" +out2 = RESULTS_DIR / "privacy_cost.png" fig.savefig(out2, dpi=150) print(f"Saved {out2}") plt.close() diff --git a/benchmark/scripts/prepare_data.py b/benchmark/dp/scripts/prepare_data.py similarity index 99% rename from benchmark/scripts/prepare_data.py rename to benchmark/dp/scripts/prepare_data.py index 1a18e067bc7..c834f024e9f 100644 --- a/benchmark/scripts/prepare_data.py +++ b/benchmark/dp/scripts/prepare_data.py @@ -38,6 +38,8 @@ from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler +from benchmark_utilities import DATA_DIR + ADULT_TRAIN_URL = ( "https://archive.ics.uci.edu/ml/machine-learning-databases" "/adult/adult.data" @@ -55,7 +57,6 @@ 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): diff --git a/benchmark/dp/scripts/requirements.txt b/benchmark/dp/scripts/requirements.txt new file mode 100644 index 00000000000..45d4f9409c2 --- /dev/null +++ b/benchmark/dp/scripts/requirements.txt @@ -0,0 +1,10 @@ +numpy +scipy +py4j +wheel +requests +setuptools + +scikit-learn +matplotlib + diff --git a/benchmark/scripts/run_benchmark.sh b/benchmark/dp/scripts/run_benchmark.sh similarity index 78% rename from benchmark/scripts/run_benchmark.sh rename to benchmark/dp/scripts/run_benchmark.sh index 52226c43f28..22818fc9358 100755 --- a/benchmark/scripts/run_benchmark.sh +++ b/benchmark/dp/scripts/run_benchmark.sh @@ -18,19 +18,20 @@ # under the License. # #------------------------------------------------------------- +BENCHMARK_DIR=benchmark/dp # 1. Prepare data (once). -python benchmark/scripts/prepare_data.py +python ${BENCHMARK_DIR}/scripts/prepare_data.py # 2. Run the sweep (starts workers, trains, evaluates, stops workers). -bash benchmark/scripts/run_sweep.sh +bash ${BENCHMARK_DIR}/scripts/run_sweep.sh # 3. Collect results into CSV. -python benchmark/scripts/collect_results.py +python ${BENCHMARK_DIR}/scripts/collect_results.py # 4. Generate plots. -python benchmark/scripts/plot.py +python ${BENCHMARK_DIR}/scripts/plot.py # 5. Confirm outputs exist. -ls -lh benchmark/results/accuracy_vs_epsilon.png benchmark/results/privacy_cost.png +ls -lh ${BENCHMARK_DIR}/results/accuracy_vs_epsilon.png ${BENCHMARK_DIR}/results/privacy_cost.png diff --git a/benchmark/scripts/run_sweep.sh b/benchmark/dp/scripts/run_sweep.sh similarity index 100% rename from benchmark/scripts/run_sweep.sh rename to benchmark/dp/scripts/run_sweep.sh diff --git a/benchmark/scripts/start_workers.sh b/benchmark/dp/scripts/start_workers.sh similarity index 100% rename from benchmark/scripts/start_workers.sh rename to benchmark/dp/scripts/start_workers.sh diff --git a/benchmark/scripts/stop_workers.sh b/benchmark/dp/scripts/stop_workers.sh similarity index 100% rename from benchmark/scripts/stop_workers.sh rename to benchmark/dp/scripts/stop_workers.sh