The Energy of Optimization in Designing Experiments Involving Small Samples | by Leandro Magga | Oct, 2024

A step-by-step information to designing extra exact experiments utilizing optimization in Python

Experimentation is the inspiration for testing hypotheses with scientific rigor. In medication, it’s used to evaluate the impact of recent therapies on sufferers, whereas within the digital world, tech giants like Amazon, Netflix, and Uber run 1000’s of experiments every year to optimize and enhance their platforms.

For giant-scale experiments, random project is often used and its thought of the “Gold Normal”. With a considerable quantity of information, randomness tends to provide comparable teams, the place necessary pre-experiment elements are balanced and the exchangeability assumption holds.

Nevertheless, when the pattern for an experiment could be very small, random project usually fails to create statistically equal teams. So, how can we break up models effectively between remedy and management teams?

What you’ll study:

On this submit, I’ll clarify an optimization-based strategy to developing equal teams for an experiment which was proposed by Bertsimas et al. in this text.

With a easy instance in Python we’ll learn to:

  • Design an optimization-based experiment.
  • Carry out inference utilizing bootstrap methods.
  • Implement the code in Python in your personal experiments

Earlier than diving into our instance and Python code, let’s first focus on some insights into the advantages of utilizing an optimization-based strategy to design experiments.

Optimization makes statistics extra exact, permitting for a extra highly effective inference.

The optimization-based strategy matches the experimental teams to attenuate the en-masse discrepancies in means and variances. This makes the statistics rather more exact, concentrating them tightly round their nominal values whereas nonetheless being unbiased estimates. This elevated precision permits for extra highly effective inference (utilizing the bootstrap algorithm, which we’ll cowl later).

This permits researchers to attract statistically legitimate conclusions with much less knowledge, decreasing experimental prices — an necessary profit in disciplines like oncology analysis, the place testing chemotherapy brokers in mouse most cancers fashions is each laborious and costly. Furthermore, in comparison with different strategies for small pattern sizes, optimization has been proven to outperform them, as we’ll see later with simulated experiments.

Now, let’s dive into an instance to see methods to apply this strategy utilizing Python!

Case Research: A Drug Experiment with 20 Mice

Let’s contemplate an experiment with 20 mice, the place we’re keen on learning the impact of a drug on tumor progress with various preliminary tumor sizes. Suppose that the preliminary tumor sizes are usually distributed with a imply of 200 mg and an ordinary deviation of 300 mg (truncated to make sure non-negative values). We are able to generate the inhabitants of mice with the next Python code:

import numpy as np
import pandas as pd
import scipy.stats as stats

def create_experiment_data(n_mice, mu, sigma, seed):
decrease, higher = 0, np.inf
initial_weights = stats.truncnorm(
(decrease - mu) / sigma,
(higher - mu) / sigma,
loc=mu,
scale=sigma
).rvs(measurement=n_mice, random_state=seed)
return pd.DataFrame({
'mice': checklist(vary(1, n_mice+1)),
'initial_weight': initial_weights,
})

tumor_data = create_experiment_data(n_mice=20, mu=200, sigma=300, seed=123)
print(tumor_data.head())

>    mice  initial_weight
0 1 424.736888
1 2 174.691035
2 3 141.016478
3 4 327.518749
4 5 442.239789

Now, we have to divide the 20 rodents into two teams of 10 every — one group will obtain the remedy, whereas the opposite will obtain a placebo. We’ll accomplish this utilizing optimization.

We can even assume that tumor progress is noticed over a one-day interval and follows the Gompertz mannequin (detailed in Mathematical Fashions in Most cancers Analysis). The remedy is assumed to have a deterministic impact of decreasing tumor measurement by 250 mg.

Experimental Design

We purpose to formulate the creation of equal teams as an optimization drawback, the place the target is to attenuate the discrepancy in each the imply and variance of the preliminary tumor weight.

To implement this, we have to comply with three steps:

Paso 1: Normalize the Preliminary Tumor Weight

First, your entire pattern have to be pre-processed and the metric needs to be normalized in order that it has a imply of zero and unit variance:

imply = tumor_data['initial_weight'].imply()
std = tumor_data['initial_weight'].std()

tumor_data['norm_initial_weight'] = (tumor_data['initial_weight'] - imply) / std

Paso 2: Create the teams utilizing optimization

Subsequent, we have to implement the overall optimization mannequin that assemble m-groups with k-subjects every, minimizing the most discrepancy between any two teams (a full description of the mannequin variables may be present in the article) and passing it the normalized metric:

Optimization mannequin that creates m-groups of k-units every (from Bertsimas et al. 2015).

The mathematical mannequin may be applied in Python utilizing the ortools library with the SCIP solver, as follows:

from ortools.linear_solver import pywraplp
from typing import Union

class SingleFeatureOptimizationModel:
"""
Implements the discrete optimization mannequin proposed by Bertsimas et al. (2015) in "The Energy of
Optimization Over Randomization in Designing Experiments Involving Small Samples".
See: https://doi.org/10.1287/opre.2015.1361.
"""

def __init__(self, knowledge: pd.DataFrame, n_groups: int, units_per_group: int, metric: str, unit: str):
self.knowledge = knowledge.reset_index(drop=True)
self.parameters = {
'rho': 0.5,
'teams': vary(n_groups),
'models': vary(len(self.knowledge)),
'unit_list': self.knowledge[unit].tolist(),
'metric_list': self.knowledge[metric].tolist(),
'units_per_group': units_per_group,
}
self._create_solver()
self._add_vars()
self._add_constraints()
self._set_objective()

def _create_solver(self):
self.mannequin = pywraplp.Solver.CreateSolver("SCIP")
if self.mannequin is None:
increase Exception("Did not create SCIP solver")

def _add_vars(self):
self.d = self.mannequin.NumVar(0, self.mannequin.infinity(), "d")
self.x = {}
for i in self.parameters['units']:
for p in self.parameters['groups']:
self.x[i, p] = self.mannequin.IntVar(0, 1, "")

def _set_objective(self):
self.mannequin.Decrease(self.d)

def _add_constraints(self):
self._add_constraints_d_bounding()
self._add_constraint_group_size()
self._add_constraint_all_units_assigned()

def _add_constraints_d_bounding(self):
rho = self.parameters['rho']
for p in self.parameters['groups']:
for q in self.parameters['groups']:
if p < q:
self.mannequin.Add(self.d >= self._mu(p) - self._mu(q) + rho * self._var(p) - rho * self._var(q))
self.mannequin.Add(self.d >= self._mu(p) - self._mu(q) + rho * self._var(q) - rho * self._var(p))
self.mannequin.Add(self.d >= self._mu(q) - self._mu(p) + rho * self._var(p) - rho * self._var(q))
self.mannequin.Add(self.d >= self._mu(q) - self._mu(p) + rho * self._var(q) - rho * self._var(p))

def _add_constraint_group_size(self):
for p in self.parameters['groups']:
self.mannequin.Add(
self.mannequin.Sum([
self.x[i,p] for i in self.parameters['units']
]) == self.parameters['units_per_group']
)

def _add_constraint_all_units_assigned(self):
for i in self.parameters['units']:
self.mannequin.Add(
self.mannequin.Sum([
self.x[i,p] for p in self.parameters['groups']
]) == 1
)

def _add_contraint_symmetry(self):
for i in self.parameters['units']:
for p in self.parameters['units']:
if i < p:
self.mannequin.Add(
self.x[i,p] == 0
)

def _mu(self, p):
mu = self.mannequin.Sum([
(self.x[i,p] * self.parameters['metric_list'][i]) / self.parameters['units_per_group']
for i in self.parameters['units']
])
return mu

def _var(self, p):
var = self.mannequin.Sum([
(self.x[i,p]*(self.parameters['metric_list'][i])**2) / self.parameters['units_per_group']
for i in self.parameters['units']
])
return var

def optimize(
self,
max_run_time: int = 60,
max_solution_gap: float = 0.05,
max_solutions: Union[int, None] = None,
num_threads: int = -1,
verbose: bool = False
):
"""
Runs the optimization mannequin.

Args:
max_run_time: int
Most run time in minutes.
max_solution_gap: float
Most hole with the LP rest answer.
max_solutions: int
Most variety of options till cease.
num_threads: int
Variety of threads to make use of in solver.
verbose: bool
Whether or not to set the solver output.

Returns: str
The standing of the answer.
"""
self.mannequin.SetTimeLimit(max_run_time * 60 * 1000)
self.mannequin.SetNumThreads(num_threads)

if verbose:
self.mannequin.EnableOutput()

self.mannequin.SetSolverSpecificParametersAsString(f"limits/hole = {max_solution_gap}")
self.mannequin.SetSolverSpecificParametersAsString(f"limits/time = {max_run_time * 60}")

if max_solutions:
self.mannequin.SetSolverSpecificParametersAsString(f"limits/options = {max_solutions}")

standing = self.mannequin.Resolve()

if verbose:
if standing == pywraplp.Solver.OPTIMAL:
print("Optimum Resolution Discovered.")
elif standing == pywraplp.Solver.FEASIBLE:
print("Possible Resolution Discovered.")
else:
print("Downside infeasible or unbounded.")

self._extract_solution()
return standing

def _extract_solution(self):
tol = 0.01
self.project = {}
for i in self.parameters['units']:
for p in self.parameters['groups']:
if self.x[i,p].solution_value() > tol:
self.project.setdefault(p, []).append(self.parameters['unit_list'][i])

def get_groups_list(self):
return checklist(self.project.values())

mannequin = SingleFeatureOptimizationModel(
knowledge = tumor_data,
n_groups = 2,
units_per_group = 10,
unit = 'mice',
metric = 'norm_initial_weight',

)

standing = mannequin.optimize()
optimized_groups = mannequin.get_groups_list()
print(f"The optimized mice teams are: {optimized_groups}")

> The optimized mice teams are: [
[1, 4, 5, 6, 8, 12, 14, 16, 17, 18],
[2, 3, 7, 9, 10, 11, 13, 15, 19, 20]
]

Word: The parameter rho controls the trade-off between minimizing discrepancies within the first second and second second and is chosen by the researcher. In our instance, we have now thought of rho equals 0.5.

Paso 3: Randomize which group receives which remedy

Lastly, we randomly decide which experimental mice group will obtain the drug, and which can obtain the placebo:

import random

def random_shuffle_groups(group_list, seed):
random.seed(seed)
random.shuffle(group_list)
return group_list

randomized_groups = random_shuffle_groups(optimized_groups, seed=123)
treatment_labels = ["Placebo", "Treatment"]
treatment_dict = {treatment_labels[i]: randomized_groups[i] for i in vary(len(randomized_groups))}
print(f"The remedy project is: {treatment_dict}")

> The remedy project is: {
'Placebo': [2, 3, 7, 9, 10, 11, 13, 15, 19, 20],
'Therapy': [1, 4, 5, 6, 8, 12, 14, 16, 17, 18]
}

Let’s see the standard of the outcome by analyzing the imply and variance of the preliminary tumor weights in each teams:

mice_assignment_dict = {inx: gp for gp, indices in treatment_dict.gadgets() for inx in indices}
tumor_data['treatment'] = tumor_data['mice'].map(mice_assignment_dict)

print(tumor_data.groupby('remedy').agg(
avg_initial_weight = ('initial_weight', 'imply'),
std_initial_weight = ('initial_weight', 'std'),
).spherical(2))

>          avg_initial_weight  std_initial_weight
remedy
Placebo 302.79 202.54
Therapy 303.61 162.12

That is the group division with minimal discrepancy within the imply and variance of pre-experimental tumor weights! Now let’s conduct the experiment and analyze the outcomes.

Simulating tumor progress

Given the established remedy project, tumor progress is simulated over in the future utilizing the Gompertz mannequin, assuming an impact of -250 mg for the handled group:

import numpy as np
from scipy.combine import odeint

# Gompertz mannequin parameters
a = 1
b = 5

# Essential weight
wc = 400

def gompex_growth(w, t):
"""
Gomp-ex differential equation mannequin based mostly on the preliminary weight.
"""
growth_rate = a + max(0, b * np.log(wc / w))
return w * growth_rate

def simulate_growth(w_initial, t_span):
"""
Simulate the tumor progress utilizing the Gomp-ex mannequin.
"""
return odeint(gompex_growth, w_initial, t_span).flatten()

def simulate_tumor_growth(knowledge: pd.DataFrame, initial_weight: str, treatment_col: str, treatment_value: str, treatment_effect: float):
"""
Simulate the tumor progress experiment and return the dataset.
"""
t_span = np.linspace(0, 1, 2)
final_weights = np.array([simulate_growth(w, t_span)[-1] for w in knowledge[initial_weight]])

experiment_data = knowledge.copy()
mask_treatment = knowledge[treatment_col] == treatment_value
experiment_data['final_weight'] = np.the place(mask_treatment, final_weights + treatment_effect, final_weights)

return experiment_data.spherical(2)

experiment_data = simulate_tumor_growth(
knowledge = tumor_data,
initial_weight = 'initial_weight',
treatment_col = 'remedy',
treatment_value = 'Therapy',
treatment_effect = -250
)

print(experiment_data.head())

>   mice  initial_weight  norm_initial_weight  remedy  final_weight
0 1 424.74 0.68 Therapy 904.55
1 2 174.69 -0.72 Placebo 783.65
2 3 141.02 -0.91 Placebo 754.56
3 4 327.52 0.14 Therapy 696.60
4 5 442.24 0.78 Therapy 952.13
mask_tr = experiment_data.group == 'Therapy'
mean_tr = experiment_data[mask_tr]['final_weight'].imply()
mean_co = experiment_data[~mask_tr]['final_weight'].imply()
print(f"Imply distinction between remedy and management: {spherical(mean_tr - mean_co)} mg")
> Imply distinction between remedy and management: -260 mg

Now that we have now the ultimate tumor weights, we observe that the common remaining tumor weight within the remedy group is 260 mg decrease than within the management group. Nevertheless, to find out if this distinction is statistically vital, we have to apply the next bootstrap mechanism to calculate the p-value.

Bootstrap inference for optimization-based design

“In an optimization-based design, statistics like the common distinction between teams turn into extra exact however not comply with the same old distributions. Subsequently, a bootstrap inference technique needs to be used to attract legitimate conclusions.”

The boostrap inference technique proposed by Bertsimas et al. (2015) entails utilizing sampling with substitute to assemble the baseline distribution of our estimator. In every iteration, the group division is carried out utilizing optimization, and eventually the the p-value is derived as follows:

from tqdm import tqdm
from typing import Any, Record

def inference(knowledge: pd.DataFrame, unit: str, end result: str, remedy: str, treatment_value: Any = 1, n_bootstrap: int = 1000) -> pd.DataFrame:
"""
Estimates the p-value utilizing bootstrap for 2 teams.

Parameters
-----------
knowledge (pd.DataFrame): The experimental dataset with the noticed end result.
unit (str): The experimental unit column.
end result (str): The end result metric column.
remedy (str): The remedy column.
treatment_value (Any): The worth referencing the remedy (different can be thought of as management).
n_bootstrap (int): The variety of random attracts with substitute to make use of.

Returns
-----------
pd.DataFrame: The dataset with the outcomes.

Increase
------------
ValueException: if there are greater than two remedy values.
"""
responses = knowledge[outcome].values
mask_tr = (knowledge[treatment] == treatment_value).values
delta_obs = _compute_delta(responses[mask_tr], responses[~mask_tr])
deltas_B = _run_bootstrap(knowledge, unit, end result, n_bootstrap)
pvalue = _compute_pvalue(delta_obs, deltas_B)
output_data = pd.DataFrame({
'delta': [delta_obs],
'pvalue': [pvalue],
'n_bootstrap': [n_bootstrap],
'avg_delta_bootstrap': [np.round(np.mean(deltas_B), 2)],
'std_delta_bootstrap': [np.round(np.std(deltas_B), 2)]
})
return output_data

def _run_bootstrap(knowledge: pd.DataFrame, unit: str, end result: str, B: int = 1000) -> Record[float]:
"""
Runs the bootstrap technique and returns the bootstrapped deltas.

Parameters
-----------
knowledge (pd.DataFrame): The dataframe from which pattern with substitute.
end result (str): The end result metric noticed within the experiment.
B (int): The variety of random attracts with substitute to perfrom.

Returns
-----------
Record[float]: The checklist of bootstrap deltas.
"""
deltas_bootstrap = []
for i in tqdm(vary(B), desc="Bootstrap Progress"):
sample_b = _random_draw_with_replacement(knowledge, unit)
responses_b, mask_tr_b = _optimal_treatment_control_split(sample_b, unit, end result, seed=i)
delta_b = _compute_delta(responses_b[mask_tr_b], responses_b[~mask_tr_b])
deltas_bootstrap.append(delta_b)

return deltas_bootstrap

def _compute_delta(response_tr, responses_co):
delta = np.imply(response_tr) - np.imply(responses_co)
return delta

def _compute_pvalue(obs_delta, bootstrap_deltas):
count_extreme = sum(1 for delta_b in bootstrap_deltas if abs(delta_b) >= abs(obs_delta))
p_value = (1 + count_extreme) / (1 + len(bootstrap_deltas))
return p_value

def _random_draw_with_replacement(knowledge: pd.DataFrame, unit: str):
pattern = knowledge.pattern(frac=1, exchange=True)
pattern[unit] = vary(1, len(pattern) + 1)
return pattern

def _optimal_treatment_control_split(knowledge: pd.DataFrame, unit: str, end result: str, seed: int):
outcome = _sample(
knowledge = knowledge,
unit = unit,
normalized_feature = 'norm_initial_weight',
seed = seed
)
treatment_dict = {inx: gp for gp, indices in outcome.gadgets() for inx in indices}
remedy = knowledge[unit].map(treatment_dict)
mask_tr = (remedy == 'Therapy').values
responses = knowledge[outcome].values
return responses, mask_tr

def _sample(knowledge: pd.DataFrame, unit: str, normalized_feature: str, seed: int):
mannequin = SingleFeatureOptimizationModel(
knowledge,
n_groups = 2,
units_per_group = 10,
unit = unit,
metric = normalized_feature,
)
standing = mannequin.optimize()
optimized_groups = mannequin.get_groups_list()
randomized_groups = random_shuffle_groups(optimized_groups, seed=seed)
treatment_labels = ["Placebo", "Treatment"]
return {treatment_labels[i]: randomized_groups[i] for i in vary(len(randomized_groups))}

infer_result = inference(
knowledge = experiment_data,
unit = 'mice',
end result = 'final_weight',
remedy = 'group',
treatment_value = 'Therapy',
n_bootstrap = 1000
)

print(infer_result)

>     delta    pvalue  n_bootstrap  avg_delta_bootstrap  std_delta_bootstrap
0 -260.183 0.001998 1000 2.02 112.61

The noticed distinction of -260 mg between the teams is important on the 5% significance stage (the p-value lower than 0.05). Subsequently, we reject the null speculation of equal means and conclude that the remedy had a statistically vital impact.

We are able to simulate the experiment a number of occasions, producing populations of mice with completely different preliminary tumor weights, drawn from the identical regular distribution with a imply of 200 mg and an ordinary deviation of 300 mg.

This permits us to check the optimization-based design with different experimental designs. Within the following graph, I evaluate the optimization strategy with easy random project and stratified random project (the place the strata have been created utilizing a k-means algorithm based mostly on preliminary tumor weight):

Outcomes of 1000 simulated experiments to detect an impact of -250 mg (Picture by creator).

Of their article, the authors additionally evaluate the optimization-based strategy with re-randomization and pairwise matching throughout completely different impact sizes and group sizes. I extremely advocate studying the complete article in case you’re keen on exploring the main points additional!