Skip to content

Commit 14d3ce9

Browse files
committed
Removed CV and Auttocorrelation method, replaced with new method of finding equilibrium when upward or downward trending stops
1 parent f2075e8 commit 14d3ce9

1 file changed

Lines changed: 82 additions & 99 deletions

File tree

scripts/warmup_study.py

Lines changed: 82 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -89,80 +89,69 @@ class WarmupStudyConfig:
8989
# EQUILIBRATION DETECTION
9090
# =============================================================================
9191

92-
def estimate_equilibration_cv(
92+
def estimate_equilibration_trend(
9393
time_series: np.ndarray,
9494
sample_interval: int,
9595
window: int = 10,
9696
threshold: float = 0.02,
9797
) -> int:
9898
"""
99-
Estimate equilibration step using coefficient of variation (CV).
100-
101-
Returns the step when rolling CV drops below threshold for a sustained period.
99+
Estimate equilibration step by detecting when the mean stops trending.
100+
101+
This method checks if consecutive rolling means are stable (not changing
102+
significantly). Unlike CV-based detection, this is robust to grid size
103+
because it measures actual trend, not fluctuation magnitude.
104+
105+
Parameters
106+
----------
107+
time_series : np.ndarray
108+
Population density or count over time.
109+
sample_interval : int
110+
Number of simulation steps between samples.
111+
window : int
112+
Size of rolling window for computing means.
113+
threshold : float
114+
Maximum allowed relative change between consecutive window means.
115+
Default 0.02 means the mean can change by at most 2% between windows.
116+
117+
Returns
118+
-------
119+
int
120+
Estimated equilibration step.
102121
"""
103-
if len(time_series) < window * 2:
122+
if len(time_series) < window * 3:
104123
return len(time_series) * sample_interval
105124

106125
# Skip initial transient (first 10% of data)
107-
start_idx = max(window, len(time_series) // 10)
126+
start_idx = max(window * 2, len(time_series) // 10)
108127

109-
for i in range(start_idx, len(time_series)):
110-
recent = time_series[i-window:i]
111-
mean_val = np.mean(recent)
112-
113-
if mean_val > 0:
114-
cv = np.std(recent) / mean_val
115-
if cv < threshold:
116-
# Check if it stays stable for another window
117-
if i + window < len(time_series):
118-
next_window = time_series[i:i+window]
119-
next_cv = np.std(next_window) / np.mean(next_window) if np.mean(next_window) > 0 else 1.0
120-
if next_cv < threshold * 1.5:
121-
return i * sample_interval
122-
else:
123-
return i * sample_interval
128+
# Number of consecutive stable windows required
129+
required_stable = 3
130+
stable_count = 0
124131

125-
return len(time_series) * sample_interval
126-
127-
128-
def estimate_equilibration_autocorr(
129-
time_series: np.ndarray,
130-
sample_interval: int,
131-
lag_threshold: float = 0.1,
132-
) -> Tuple[int, float]:
133-
"""
134-
Estimate equilibration via autocorrelation decay.
135-
136-
Returns (equilibration_step, integrated_autocorrelation_time).
137-
"""
138-
n = len(time_series)
139-
if n < 10:
140-
return n * sample_interval, 0.0
141-
142-
mean = np.mean(time_series)
143-
var = np.var(time_series)
144-
145-
if var == 0:
146-
return 0, 0.0
147-
148-
# Compute autocorrelation function
149-
acf = np.correlate(time_series - mean, time_series - mean, mode='full')
150-
acf = acf[n-1:] / (var * n)
151-
152-
# Integrated autocorrelation time
153-
tau_int = 1.0
154-
for lag in range(1, min(n // 4, 100)):
155-
if acf[lag] > 0:
156-
tau_int += 2 * acf[lag]
132+
for i in range(start_idx, len(time_series) - window):
133+
# Compare means of two consecutive windows
134+
window1 = time_series[i - window:i]
135+
window2 = time_series[i:i + window]
136+
137+
mean1 = np.mean(window1)
138+
mean2 = np.mean(window2)
139+
140+
if mean1 > 0 and mean2 > 0:
141+
# Relative change in mean
142+
relative_change = abs(mean2 - mean1) / mean1
143+
144+
if relative_change < threshold:
145+
stable_count += 1
146+
if stable_count >= required_stable:
147+
# Return the step where stability began
148+
return (i - required_stable * window // 2) * sample_interval
149+
else:
150+
stable_count = 0 # Reset if trend detected
157151
else:
158-
break
152+
stable_count = 0 # Reset if population crashed
159153

160-
# Find where ACF drops below threshold
161-
for lag, corr in enumerate(acf):
162-
if corr < lag_threshold:
163-
return lag * sample_interval, tau_int
164-
165-
return len(acf) * sample_interval, tau_int
154+
return len(time_series) * sample_interval
166155

167156

168157
# =============================================================================
@@ -200,9 +189,7 @@ def set_numba_seed(seed): pass
200189

201190
size_results = {
202191
'time_per_step': [],
203-
'equilibration_steps_cv': [],
204-
'equilibration_steps_acf': [],
205-
'integrated_autocorr_time': [],
192+
'equilibration_steps': [],
206193
'final_prey_density': [],
207194
'final_pred_density': [],
208195
}
@@ -250,43 +237,33 @@ def set_numba_seed(seed): pass
250237
prey_densities = np.array(prey_densities)
251238
pred_densities = np.array(pred_densities)
252239

253-
# Estimate equilibration using both methods
254-
eq_steps_cv = estimate_equilibration_cv(
240+
# Estimate equilibration (trend-based, robust to grid size)
241+
eq_steps = estimate_equilibration_trend(
255242
prey_densities,
256243
cfg.sample_interval,
257244
window=cfg.equilibration_window,
258245
threshold=cfg.equilibration_threshold,
259246
)
260247

261-
eq_steps_acf, tau_int = estimate_equilibration_autocorr(
262-
prey_densities,
263-
cfg.sample_interval,
264-
)
265-
266248
size_results['time_per_step'].append(time_per_step)
267-
size_results['equilibration_steps_cv'].append(eq_steps_cv)
268-
size_results['equilibration_steps_acf'].append(eq_steps_acf)
269-
size_results['integrated_autocorr_time'].append(tau_int)
249+
size_results['equilibration_steps'].append(eq_steps)
270250
size_results['final_prey_density'].append(prey_densities[-1])
271251
size_results['final_pred_density'].append(pred_densities[-1])
272252

273253
if (rep + 1) % max(1, cfg.n_replicates // 5) == 0:
274254
logger.info(f" Replicate {rep+1}/{cfg.n_replicates}: "
275-
f"eq_steps={eq_steps_cv}, time/step={time_per_step*1000:.2f}ms")
255+
f"eq_steps={eq_steps}, time/step={time_per_step*1000:.2f}ms")
276256

277257
# Aggregate results
278258
results[L] = {
279259
'grid_size': L,
280260
'grid_cells': L * L,
281261
'mean_time_per_step': float(np.mean(size_results['time_per_step'])),
282262
'std_time_per_step': float(np.std(size_results['time_per_step'])),
283-
'mean_eq_steps_cv': float(np.mean(size_results['equilibration_steps_cv'])),
284-
'std_eq_steps_cv': float(np.std(size_results['equilibration_steps_cv'])),
285-
'mean_eq_steps_acf': float(np.mean(size_results['equilibration_steps_acf'])),
286-
'std_eq_steps_acf': float(np.std(size_results['equilibration_steps_acf'])),
287-
'mean_tau_int': float(np.mean(size_results['integrated_autocorr_time'])),
263+
'mean_eq_steps': float(np.mean(size_results['equilibration_steps'])),
264+
'std_eq_steps': float(np.std(size_results['equilibration_steps'])),
288265
'mean_total_warmup_time': float(
289-
np.mean(size_results['equilibration_steps_cv']) *
266+
np.mean(size_results['equilibration_steps']) *
290267
np.mean(size_results['time_per_step'])
291268
),
292269
'mean_final_prey_density': float(np.mean(size_results['final_prey_density'])),
@@ -297,8 +274,8 @@ def set_numba_seed(seed): pass
297274
logger.info(f"\n Summary for L={L}:")
298275
logger.info(f" Time per step: {results[L]['mean_time_per_step']*1000:.2f} ± "
299276
f"{results[L]['std_time_per_step']*1000:.2f} ms")
300-
logger.info(f" Equilibration (CV): {results[L]['mean_eq_steps_cv']:.0f} ± "
301-
f"{results[L]['std_eq_steps_cv']:.0f} steps")
277+
logger.info(f" Equilibration steps: {results[L]['mean_eq_steps']:.0f} ± "
278+
f"{results[L]['std_eq_steps']:.0f}")
302279
logger.info(f" Total warmup time: {results[L]['mean_total_warmup_time']:.2f} s")
303280

304281
return results
@@ -342,17 +319,11 @@ def plot_warmup_scaling(
342319

343320
# Panel 2: Equilibration steps vs L (log-log)
344321
ax = axes[1]
345-
eq_steps = [results[L]['mean_eq_steps_cv'] for L in sizes]
346-
eq_stds = [results[L]['std_eq_steps_cv'] for L in sizes]
347322

323+
eq_steps = [results[L]['mean_eq_steps'] for L in sizes]
324+
eq_stds = [results[L]['std_eq_steps'] for L in sizes]
348325
ax.errorbar(sizes, eq_steps, yerr=eq_stds, fmt='o-', capsize=5,
349-
linewidth=2, color='forestgreen', markersize=8, label='CV method')
350-
351-
# Also plot ACF method
352-
eq_steps_acf = [results[L]['mean_eq_steps_acf'] for L in sizes]
353-
eq_stds_acf = [results[L]['std_eq_steps_acf'] for L in sizes]
354-
ax.errorbar(sizes, eq_steps_acf, yerr=eq_stds_acf, fmt='s--', capsize=5,
355-
linewidth=2, color='darkorange', markersize=6, alpha=0.7, label='ACF method')
326+
linewidth=2, color='forestgreen', markersize=8)
356327

357328
ax.set_xscale('log')
358329
ax.set_yscale('log')
@@ -426,9 +397,10 @@ def plot_scaling_summary(
426397
# Plot equilibration steps normalized by theoretical scaling
427398
# Try different z values
428399
for z, color, style in [(1.0, 'green', '--'), (1.5, 'orange', '-.'), (2.0, 'red', ':')]:
429-
eq_normalized = [results[L]['mean_eq_steps_cv'] / (L**z) for L in sizes]
400+
eq_normalized = [results[L]['mean_eq_steps'] / (L**z) for L in sizes]
430401
# Normalize to first point for comparison
431-
eq_normalized = [x / eq_normalized[0] for x in eq_normalized]
402+
if eq_normalized[0] > 0:
403+
eq_normalized = [x / eq_normalized[0] for x in eq_normalized]
432404
ax.plot(sizes, eq_normalized, style, color=color, linewidth=2, alpha=0.7,
433405
label=f'Eq. steps / L^{z:.1f} (normalized)')
434406

@@ -554,15 +526,26 @@ def main():
554526

555527
# Compute scaling exponents
556528
if len(sizes) >= 2:
557-
eq_steps = [results[L]['mean_eq_steps_cv'] for L in sizes]
529+
eq_steps = [results[L]['mean_eq_steps'] for L in sizes]
558530
total_times = [results[L]['mean_total_warmup_time'] for L in sizes]
559531

560-
log_L = np.log(sizes)
561-
log_eq = np.log(eq_steps)
562-
log_T = np.log(total_times)
532+
# Filter out any zero or negative values for log
533+
valid_eq = [(L, eq) for L, eq in zip(sizes, eq_steps) if eq > 0]
534+
valid_T = [(L, T) for L, T in zip(sizes, total_times) if T > 0]
563535

564-
z_eq, _, r_eq, _, _ = linregress(log_L, log_eq)
565-
z_total, _, r_total, _, _ = linregress(log_L, log_T)
536+
if len(valid_eq) >= 2:
537+
log_L_eq = np.log([x[0] for x in valid_eq])
538+
log_eq = np.log([x[1] for x in valid_eq])
539+
z_eq, _, r_eq, _, _ = linregress(log_L_eq, log_eq)
540+
else:
541+
z_eq, r_eq = 0, 0
542+
543+
if len(valid_T) >= 2:
544+
log_L_T = np.log([x[0] for x in valid_T])
545+
log_T = np.log([x[1] for x in valid_T])
546+
z_total, _, r_total, _, _ = linregress(log_L_T, log_T)
547+
else:
548+
z_total, r_total = 0, 0
566549

567550
logger.info(f"Equilibration steps scaling: t_eq ~ L^{z_eq:.2f} (R² = {r_eq**2:.3f})")
568551
logger.info(f"Total warmup time scaling: T_warmup ~ L^{z_total:.2f} (R² = {r_total**2:.3f})")

0 commit comments

Comments
 (0)