Adding Algorithms
This guide shows how to implement your own DP algorithm and benchmark it against built-in baselines.
Algorithm Structure
All algorithms inherit from BaseAlgorithm and must:
- Be decorated with
@Task.register_algorithm - Implement the
run()method (the single abstract method onBaseAlgorithm— see Implementingrun()below) - Return the appropriate output type for the task
Quick Example
Here's a simple algorithm for mean estimation:
from typing import Any
import numpy as np
from dp_testing_platform.core import BaseAlgorithm, accounting
from dp_testing_platform.core.dp_budget import DPBudget
from dp_testing_platform.tasks.mean_estimation import MeanEstimationTask
@MeanEstimationTask.register_algorithm
class MyDPMean(BaseAlgorithm):
"""My differentially private mean estimator."""
def __init__(
self,
clip_norm: float = 1.0,
name: str | None = None,
description: str | None = None,
metadata: dict[str, Any] | None = None,
):
super().__init__(name=name, description=description, metadata=metadata)
self.clip_norm = clip_norm
def run(
self,
metadata, # MeanEstimationMetadata
input, # MeanEstimationInput
budget: DPBudget,
seed: int,
):
rng = np.random.default_rng(seed)
X = input.X
n = metadata.n_samples
# Clip each row to bounded L2 norm
norms = np.linalg.norm(X, axis=1, keepdims=True)
X_clipped = X * np.minimum(1, self.clip_norm / norms)
# Compute mean
mean_est = np.mean(X_clipped, axis=0)
# Add Gaussian noise for privacy
sensitivity = 2 * self.clip_norm / n
sigma = accounting.calibrate_gaussian_sigma(budget, sensitivity)
mean_est += rng.normal(0, sigma, size=mean_est.shape)
return self.TASK.Output(mean_est=mean_est)
Step-by-Step Guide
1. Choose a Task
First, identify which task your algorithm solves. Available tasks:
from dp_testing_platform.tasks import ALL_TASKS
for task_id, task in ALL_TASKS.items():
print(f"{task_id}: {task.name}")
2. Create Your Algorithm File
Create a new Python file (e.g., my_algorithm.py):
# my_algorithm.py
from typing import Any
import numpy as np
from dp_testing_platform.core import BaseAlgorithm
from dp_testing_platform.core.dp_budget import DPBudget
from dp_testing_platform.tasks.linear_regression import LinearRegression
@LinearRegression.register_algorithm
class MyDPLinearRegression(BaseAlgorithm):
"""My DP linear regression algorithm."""
# Define hyperparameters with defaults and ranges
HYPERPARAM_INFO = {
"clip_norm": {
"default": 1.0,
"default_range": [0.1, 1.0, 10.0],
"description": "Gradient clipping norm",
},
"learning_rate": {
"default": 0.01,
"default_range": [0.001, 0.01, 0.1],
"description": "Learning rate for gradient descent",
},
}
def __init__(
self,
clip_norm: float = 1.0,
learning_rate: float = 0.01,
name: str | None = None,
description: str | None = None,
metadata: dict[str, Any] | None = None,
):
super().__init__(name=name, description=description, metadata=metadata)
self.clip_norm = clip_norm
self.learning_rate = learning_rate
def run(
self,
metadata,
input,
budget: DPBudget,
seed: int,
):
rng = np.random.default_rng(seed)
X, y = input.X, input.y
n, d = X.shape
# Your DP algorithm implementation here
beta = np.zeros(d)
# ... gradient descent with noise ...
return self.TASK.Output(beta=beta)
3. Register Your Algorithm
Import your algorithm file to register it with the task:
Or use the CLI:
4. Run Benchmarks
Evaluate your algorithm:
dp-testing-platform evaluate \
--task-name linear_regression \
--epsilons 0.1 1.0 10.0 \
--deltas 1e-5 \
--alg-seeds 0 1 2 3 4 \
--instance-seeds 0 1 2
Your algorithm will be included alongside the built-in baselines.
The CLI flags construct concrete approximate-DP budgets here; they are convenience
syntax over the engine's single typed budget axis.
Implementing run()
run() is the single abstract method on BaseAlgorithm: subclasses implement it directly.
- An algorithm runs on instances of its own task (
self.TASKformat);run()performs no adaptation of its own. - Data passed to
run()is therefore guaranteed to be inself.TASKformat. - To reuse an algorithm on a different task, present it as a native lifted class in that task's
algorithms/lifted.py(viamake_lifted_algorithm) — seedocs/custom-tasks.md§ "Reusing Algorithms Across Tasks".run_experimentrejects an algorithm whoseTASKis not the sweep's task (the error names the donor and points at the lifted class).
The run() method receives:
| Parameter | Description |
|---|---|
metadata |
Task-specific metadata (e.g., dimensions, sample count, domain bounds) |
input |
The actual data (e.g., X matrix, y vector) |
budget |
One concrete PureDPBudget, ApproxDPBudget, or ZCDPBudget allowance |
seed |
Required integer seed for reproducibility |
The method must return an instance of self.TASK.Output.
Type Hints
For better IDE support, use explicit types:
from dp_testing_platform.tasks.mean_estimation.types import (
MeanEstimationInput,
MeanEstimationMetadata,
MeanEstimationOutput,
)
from dp_testing_platform.core.dp_budget import DPBudget
def run(
self,
metadata: MeanEstimationMetadata,
input: MeanEstimationInput,
budget: DPBudget,
seed: int,
) -> MeanEstimationOutput:
...
Failure Semantics
An algorithm has two distinct ways to not return a result, and the distinction is a privacy boundary:
NoRelease = releasing ⊥ ("no result") as a mechanism outcome. Raising
it is a release, not a crash: the failure event must be covered by the
algorithm's charged budget, because consumers are allowed to
observe and act on it — the engine records an honest no_release status row, and
the DP selection tuners score a failed candidate +inf and keep selecting.
Two kinds of raiser satisfy the contract:
- Data-independent infeasibility — the failure is a deterministic function of
public quantities (metadata, hyperparameters, and the typed
budget), so the failure bit carries no information about the private data. Example: the requested offered budget is below the mechanism's feasibility floor. - Accounted data-dependent failure — the failure probability depends on the private data and the algorithm's privacy analysis covers the joint release over outputs-plus-⊥. Example: a Propose-Test-Release check failure. State the argument in a comment at the raise site.
Ordinary exceptions (e.g. RuntimeError) = execution errors. Anything the
privacy analysis does not account for — a solver that could not certify its
tolerance, non-convergence, numerical breakdown — must abort with an ordinary
exception. The engine records these as error rows, and the tuners re-raise
them rather than folding them into selection (a bug is not a bad candidate).
Worked example: the AMP objective-perturbation paper assumes the solver reaches
its certified gradient-norm tolerance (guaranteed in exact arithmetic for the
strongly convex objective). When certification fails at runtime anyway, the
implementation raises RuntimeError, not NoRelease: whether the
solver certifies depends on the private data, and no budget is charged for that
event — so it must surface as a crash outside the accounted release, never as a
⊥ that DP selection could observe.
Hyperparameter Specification
Define HYPERPARAM_INFO for tunable parameters:
HYPERPARAM_INFO = {
"param_name": {
"type": float, # Parameter type
"default": 1.0, # Default value
"default_range": [0.01, 1000.0], # [min, max] the tuner searches between
"scale": "log", # "log" => geometric grid, else "linear"
"description": "What this does", # Documentation
},
}
Recognized HYPERPARAM_INFO fields:
type— the parameter's Python type (float/int/str).default— value used when the__init__argument is omitted.default_range—[min, max]numeric endpoints the tuner searches between.default_search_grid()discretizes this into a grid ofn_pointsvalues (non-numeric values are enumerated verbatim, but declare categorical candidates withdefault_gridinstead).default_grid— an explicit discrete list of candidate values; takes precedence overdefault_rangeindefault_search_grid(). The required form for categorical (e.g.str-valued) parameters.scale—"log"makesdefault_search_grid()space the grid geometrically (use for magnitude-spanning clip multipliers and learning-rate-like params);"linear"(the default when absent) spaces it evenly.tunable—Falseexcludes the parameter fromdefault_search_grid()(e.g. a parameter whose off-default values are not DP-sound). Defaults toTrue.
BaseAlgorithm.default_search_grid(n_points=7) reads these fields (no data input) to build the discrete grid the DP private-selection tuners consume as alg_hyperparam_grid.
Access hyperparameters:
alg = MyAlgorithm(clip_norm=5.0)
print(alg.hyperparams) # {"clip_norm": 5.0, "learning_rate": 0.01}
print(MyAlgorithm.get_default_hyperparams()) # {"clip_norm": 1.0, "learning_rate": 0.01}
Defaulting Hyperparameters in __init__
In the library's built-in algorithms, hyperparameter arguments default to None and fall back to HYPERPARAM_INFO[...]["default"] only when the argument is omitted:
@TaskClass.register_algorithm
class MyAlgorithm(BaseAlgorithm):
HYPERPARAM_INFO = {
"learning_rate": {
"type": float,
"description": "Step size for gradient descent",
"default": 0.1,
"default_range": [0.01, 1.0],
},
"num_iters": {
"type": int,
"description": "Number of iterations",
"default": 100,
},
}
def __init__(
self,
learning_rate: float | None = None,
num_iters: int | None = None,
):
# Fall back to defaults from HYPERPARAM_INFO only when the argument is
# omitted (None). Never use `x or default` — it silently replaces
# valid falsy values like 0/0.0 with the default.
self.learning_rate = (
learning_rate
if learning_rate is not None
else self.HYPERPARAM_INFO["learning_rate"]["default"]
)
self.num_iters = (
num_iters
if num_iters is not None
else self.HYPERPARAM_INFO["num_iters"]["default"]
)
This keeps HYPERPARAM_INFO the single source of truth for defaults.
Hyperparameter Design Principle
Important
Hyperparameters must be problem-agnostic. A single hyperparameter value should work well across different problem instances (varying dimensions, sample sizes, domain bounds, etc.).
If the optimal parameter depends on problem characteristics (dimension d, sample size n, domain diameter), factor out the data-dependent part:
| Approach | Example | Verdict |
|---|---|---|
| Wrong | clip = 10.0 (assumes specific dimension) |
❌ |
| Right | clip_multiplier = 1.0, compute clip = multiplier * sqrt(d) in run() |
✅ |
This ensures:
- Users can tune hyperparameters on one problem and transfer to others
- Default values are meaningful across problem scales
- Hyperparameter search spaces don't depend on problem size
Example: Problem-Agnostic Clipping
@MeanEstimationTask.register_algorithm
class MyDPMean(BaseAlgorithm):
# Good: clip_multiplier is problem-agnostic
HYPERPARAM_INFO = {
"clip_multiplier": {
"default": 1.0,
"default_range": [0.1, 1.0, 10.0],
"description": "Multiplier for dimension-scaled clipping",
},
}
def __init__(self, clip_multiplier: float = 1.0, **kwargs):
super().__init__(**kwargs)
self.clip_multiplier = clip_multiplier
def run(self, metadata, input, budget: DPBudget, seed: int):
# Compute actual clip from metadata
d = metadata.dim
clip = self.clip_multiplier * np.sqrt(d)
# Now use clip in the algorithm...
Reading Problem Characteristics
Access these from metadata in run():
metadata.dim- Dimension of the problemmetadata.n_samples- Number of samplesmetadata.domain- Domain bounds (if bounded)metadata.domain.l2_diameter()- L2 diameter of domainmetadata.domain_lower_bound/domain_upper_bound- Scalar bounds (for 1D tasks)
Using Domain Bounds
Many DP algorithms require bounded data. Access bounds from metadata:
def run(self, metadata, input, budget: DPBudget, seed: int):
if metadata.domain is None:
raise ValueError("This algorithm requires bounded domain")
l2_diameter = metadata.domain.l2_diameter()
sensitivity = l2_diameter / metadata.n_samples
# Use sensitivity in your noise calibration
...
Example: Laplace Mechanism for Mean Estimation
@MeanEstimationTask.register_algorithm
class LaplaceMeanEstimation(BaseAlgorithm):
"""Mean estimation using the Laplace mechanism."""
def run(
self,
metadata,
input,
budget: DPBudget,
seed: int,
):
if metadata.domain is None:
raise ValueError("Laplace mechanism requires bounded domain")
eps = accounting.spend_as_pure(budget).eps
rng = np.random.default_rng(seed)
X = input.X
n = metadata.n_samples
d = metadata.dim
# Compute non-private mean
mean_est = np.mean(X, axis=0)
# Add Laplace noise after an explicit pure-DP spend.
l1_diameter = metadata.domain.l1_diameter()
sensitivity = l1_diameter / n
scale = sensitivity / eps
mean_est += rng.laplace(0, scale, size=d)
return self.TASK.Output(mean_est=mean_est)
Testing Your Algorithm
Write unit tests to verify correctness:
import numpy as np
from dp_testing_platform import ApproxDPBudget
from dp_testing_platform.tasks import ALL_TASKS
def test_my_algorithm():
task = ALL_TASKS["mean_estimation"]
# Create test data
X = np.random.randn(100, 5)
metadata = task.Metadata(dim=5, n_samples=100)
input_data = task.Input(X=X)
# Import to register
import my_algorithm
# Run algorithm
alg = my_algorithm.MyDPMean(clip_norm=1.0)
output = alg.run(metadata, input_data, ApproxDPBudget(1.0, 1e-5), seed=42)
assert output.mean_est.shape == (5,)
assert np.all(np.isfinite(output.mean_est))
Common Patterns
Non-Private Baseline
@SomeTask.register_algorithm
class NonPrivateBaseline(BaseAlgorithm):
"""Non-private baseline for comparison."""
def run(self, metadata, input, budget: DPBudget, seed: int):
# A non-private baseline ignores the offered budget.
result = compute_optimal_solution(input)
return self.TASK.Output(...)
Algorithm Requiring Specific Bounds
def run(self, metadata, input, budget: DPBudget, seed: int):
domain = metadata.domain
if domain is None:
raise ValueError("Algorithm requires bounded domain")
# For mean estimation with L2 ball domain
from dp_testing_platform.core import LpBallDomain
if not isinstance(domain, LpBallDomain):
raise ValueError("Algorithm requires LpBallDomain")
radius = domain.radius
...
Documenting Your Algorithm
Algorithm class docstrings include a Hyperparameters: section listing each hyperparameter's name, description, and default. Paper implementations cite the paper (title + arXiv/DOI link) in the class docstring:
@LinearRegression.register_algorithm
class ObjectivePerturbation(BaseAlgorithm):
"""Objective perturbation for linear regression (RKW 2023 Thm 3.1, Gaussian).
Adds a Gaussian linear term to a regularized half-Huber objective and
returns the minimizer.
The default two-stage configuration (Approximate Minima Perturbation,
Iyengar et al. 2019) is solver-robust. The "exact" no-output-stage
configuration (`ObjectivePerturbation.exact()`, output_budget_frac=0) carries
a HIDDEN ASSUMPTION (gap 2): its (eps, delta)-DP guarantee holds only for the
EXACT minimizer of the perturbed objective (Redberg-Koskela-Wang 2023), and
the numerical solver returns an approximate minimizer, so that configuration's
released coefficients are NOT certifiably (eps, delta)-DP.
Hyperparameters:
C_x_clip: Clipping constant for X. Clips rows to norm C_x_clip * sqrt(d). Default: 1.0.
huber_L: Half-Huber loss parameter (strictly positive). Default: 1.0.
lambda_mult: Dimensionless L2 regularization multiplier; the actual
regularizer is lambda_mult * R^2 with R = C_x_clip * sqrt(d). Must
exceed 1 for RKW Thm 3.1 feasibility (lambda > beta = R^2). Default: 5.0.
"""
The lambda_mult multiplier is a worked example of the hyperparameter design principle: the regularizer scales with the data radius R = C_x_clip * sqrt(d) inside run(), so a single dimensionless default transfers across problem sizes rather than baking in a fixed lambda.
Note how a privacy-relevant caveat (here, the exact-minimizer assumption) belongs in the docstring and in the registered description so it travels with results.csv.
Next Steps
- Running Evaluations - Compare your algorithm to baselines
- Adding Datasets - Test on real data
- Creating Tasks - Define new problem types