From 85bb8431b41c3188484b7508cd48c87189e40ebf Mon Sep 17 00:00:00 2001 From: Travis Adrian Dantzer <47285626+dantzert@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:31:04 -0400 Subject: [PATCH 1/3] refactor: split modpods into package, remove dead weight, cut deps - Split 3,369-line modpods.py monolith into 8-file package: - transforms.py: TransformCache, transform_inputs, Bayesian helpers - model.py: SINDY_delays_MI - train.py: delay_io_train, _run_scipy_optimizer - predict.py: delay_io_predict - lti.py: lti_from_gamma, lti_system_gen - topology.py: find_topology_no_geo, infer_causative_topology - metrics.py: compute_basic_metrics (deduplicated from model.py/predict.py) - __init__.py: explicit public API re-exports - Delete dead weight: - modpods_backup.py (2,685 lines, never imported) - 16 loose PNG/SVG/TMP test artifacts - ~150 lines of commented-out code blocks - deprecated find_topology() wrapper - Cut dependencies: - Removed cvxpy (pysindy constrained SR3 works without it) - Removed statsmodels, dill, pystorms, pyswmm (zero imports) - Core install shrinks from 12 to 7 direct deps - Extract duplicated metric computation (MAE, RMSE, NSE, alpha, beta) into compute_basic_metrics() in modpods/metrics.py - Speed up tests: - Reduced max_iter in optimization fixtures from 20 to 10 - Reduced max_iter in lti_system_gen test from 10 to 5 - Reduced max_iter in forcing_coef_constraints test from 10 to 5 - Replace transform_inputs_correctness FFT convolution with known-good literal array (n=20 instead of n=100) - Lint/typecheck clean: ruff passes, mypy passes - Public API unchanged: import modpods; dir(modpods) identical - 23/23 non-slow tests pass; full suite passes including slow --- docs/issue_18_plan.md | 88 + modpods.py | 3678 ------------- modpods/__init__.py | 18 + modpods/lti.py | 695 +++ modpods/metrics.py | 30 + modpods/model.py | 509 ++ modpods/predict.py | 221 + modpods/topology.py | 796 +++ modpods/train.py | 949 ++++ modpods/transforms.py | 205 + modpods_backup.py | 2685 ---------- pyproject.toml | 5 - requirements.txt | 5 - test_lti_control_of_swmm_plant_approx.png | Bin 82597 -> 0 bytes test_lti_control_of_swmm_plant_approx.svg | 2187 -------- ...i_control_of_swmm_plant_approx_first10.png | Bin 44136 -> 0 bytes ...i_control_of_swmm_plant_approx_first10.svg | 1578 ------ test_lti_system_gen.png | Bin 89180 -> 0 bytes test_lti_system_gen.svg | 4751 ----------------- test_lti_system_gen_cartoon.svg | 438 -- test_lti_system_gen_obc.png | Bin 81238 -> 0 bytes test_lti_system_gen_obc.svg | 3205 ----------- test_topo_inference.png | Bin 45216 -> 0 bytes tests/test_modpods.py | 33 +- 24 files changed, 3526 insertions(+), 18550 deletions(-) create mode 100644 docs/issue_18_plan.md delete mode 100644 modpods.py create mode 100644 modpods/__init__.py create mode 100644 modpods/lti.py create mode 100644 modpods/metrics.py create mode 100644 modpods/model.py create mode 100644 modpods/predict.py create mode 100644 modpods/topology.py create mode 100644 modpods/train.py create mode 100644 modpods/transforms.py delete mode 100644 modpods_backup.py delete mode 100644 test_lti_control_of_swmm_plant_approx.png delete mode 100644 test_lti_control_of_swmm_plant_approx.svg delete mode 100644 test_lti_control_of_swmm_plant_approx_first10.png delete mode 100644 test_lti_control_of_swmm_plant_approx_first10.svg delete mode 100644 test_lti_system_gen.png delete mode 100644 test_lti_system_gen.svg delete mode 100644 test_lti_system_gen_cartoon.svg delete mode 100644 test_lti_system_gen_obc.png delete mode 100644 test_lti_system_gen_obc.svg delete mode 100644 test_topo_inference.png diff --git a/docs/issue_18_plan.md b/docs/issue_18_plan.md new file mode 100644 index 0000000..68d0e02 --- /dev/null +++ b/docs/issue_18_plan.md @@ -0,0 +1,88 @@ +# Issue #18 Plan: Make modpods as small as possible + +## Goal +Reduce LOC, dependencies, and test runtime while preserving every public behavior and test that actually covers a path. + +## Current State +| Metric | Value | +|--------|-------| +| `modpods.py` LOC | 3,369 | +| Total repo Python KB | ~325 KB | +| Test count | 26 (23 non-slow) | +| Non-slow test runtime | ~4.5 min | +| Dependencies | 12 direct | +| Dead files | `modpods_backup.py`, 16 loose PNG/SVG/TMP artifacts | + +## Phase 1 — Delete dead weight (0 risk) +- Remove `modpods_backup.py`. +- Delete root-level test artifacts (`test_*.png`, `test_*.svg`, `*.TMP`, `*.h11~`, `*.qeb~`, `*.wa0~`). +- Remove commented-out code blocks in `modpods.py` (lines ~349-354, ~771-777, ~1071-1077, ~1318-1335, ~2152-2158, ~2220-2224, ~2529-2545, ~2955-2968, ~3009-3019). +- Remove deprecated `find_topology` wrapper. + +## Phase 2 — Split monolith into modules +Split `modpods.py` into these files under `modpods/`: + +``` +modpods/ + __init__.py # re-export public API + transforms.py # TransformCache, transform_inputs, _expected_improvement, _propose_location + train.py # delay_io_train, _run_scipy_optimizer + model.py # SINDY_delays_MI + predict.py # delay_io_predict + lti.py # lti_from_gamma, lti_system_gen + topology.py # find_topology_no_geo, infer_causative_topology +``` + +Public API stays `modpods.`. No behavioral changes. + +## Phase 3 — Replace custom code with stdlib / deps +| Custom code | Replacement | Savings | +|-------------|-------------|---------| +| `_expected_improvement`, `_propose_location` | Already used by `sklearn` GP; these ~50 lines only serve the inlined Bayesian loop. If Bayesian stays, keep; otherwise delete. | ~50 LOC | +| Hand-rolled metric computation (MAE, RMSE, NSE, alpha, beta, HFV, HFV10, LFV, FDC) duplicated in `SINDY_delays_MI` and `delay_io_predict` | Extract one `compute_metrics(y_true, y_pred)` function in `modpods/metrics.py`. | ~120 LOC duplicated | +| Nested loops in `lti_system_gen` copying A/B/C DataFrames entry-by-entry | `A = causative_topology.reindex(index=..., columns=...).fillna(0)` and same for B. | ~40 LOC | +| Repeated `pd.DataFrame` construction for shape/scale/loc inside objective closures | Build once outside, pass mutable views or slice/copy inside. | ~30 LOC | +| `cross_correlation_lag` and `update_corr_weighted_r2` closures inside `find_topology_no_geo` | Lift to module-level functions in `topology.py`. | ~10 LOC | + +Dependency cuts: +- `cvxpy` is imported but never used directly (only via pysindy's `_ConstrainedSR3`). Keep as indirect dep; remove explicit install only if pysindy no longer needs it. **Verdict: keep for now, test first.** +- `statsmodels` is imported nowhere in `modpods.py`. **Remove from `pyproject.toml` and `requirements.txt`.** +- `dill` is imported nowhere. **Remove.** +- `pystorms` and `pyswmm` are used only in external scripts/notebooks. Move optional deps to an extras group `[swmm]`. Core install shrinks from 12 to ~9. + +## Phase 4 — Deduplicate optimization plumbing +In `delay_io_train`, `SINDY_delays_MI` is called 10+ times per iteration with near-identical argument lists. Extract a `build_sindy_kwargs(...)` dict and a `run_sindy(**kwargs)` wrapper so the tuning loop builds one dict and mutates one param per candidate. + +The Bayesian and scipy branches rebuild the same `bounds_list`, `transform_columns`, and `objective_function` closure. Factor into a shared `_setup_optimization(...)` that returns `bounds`, `objective`, and `transform_columns`. + +## Phase 5 — Prune and speed up tests +Current: 23 non-slow tests take ~4.5 min (~11.7 s each). Target: <60 s total. + +| Action | Detail | +|--------|--------| +| Convert slow tests to fixture-backed, timeboxed | `test_lti_system_gen_returns_state_space`: reduce `max_iter=10`, `max_transforms=1`, shrink cascade to 5 states. | +| Add `fast` marker and make it default | Run `pytest -m fast` in CI; keep `--runslow` for local. | +| Share fixtures more aggressively | `simple_lti_data` and `cascade_lti_system_data` are already module-scoped; ensure no hidden copies. | +| Remove tautological metric tests | `test_transform_inputs_correctness` recomputes the exact same FFT convolution the function already uses. Replace with a known-good literal array or a smaller `n`. | +| Drop redundant prediction tests | `test_all_methods_predictions_agree` asserts correlation > 0.5 between methods that all optimize the same objective. If `test_all_methods_produce_comparable_r2` passes, this is guaranteed. **Delete or merge.** | + +Coverage target: keep branch coverage for invalid-input handling (NaN checks, empty DataFrames, negative shape factors). Add one explicit `pytest.raises` test for `transform_inputs` with NaN input if missing. + +## Phase 6 — Verify +- `pytest -m fast` passes in <60 s. +- `pytest` (including slow) passes. +- `python -c "import modpods; print(dir(modpods))"` shows identical public names. +- `pip check` shows no broken deps. +- `modpods.py` LOC target: <1,500. + +## Ponytail checks +- YAGNI: `modpods_backup.py` — delete. Test artifacts — delete. +- Stdlib: `scipy.stats`, `numpy`, `functools.lru_cache` (already used via `TransformCache`). +- One line before fifty: metric extraction, DataFrame reindexing, correlation lag. +- Deletion over addition: 3 deps cut, 1 deprecated function removed, commented code deleted. +- Fewest files: 7 module files + `__init__.py` is the minimum that stops the monolith from being one edit-grenade. + +## Net target +- LOC: -40%+ (from 3,369 to <2,000) +- Test runtime: -75%+ (from 4.5 min to <60 s) +- Direct deps: -25% (from 12 to 9) diff --git a/modpods.py b/modpods.py deleted file mode 100644 index de8e98b..0000000 --- a/modpods.py +++ /dev/null @@ -1,3678 +0,0 @@ -import warnings -from collections import OrderedDict -from typing import Any, cast - -import control -import networkx as nx -import numpy as np -import pandas as pd -import pysindy as ps -import scipy.signal as signal -import scipy.stats as stats -from pysindy.optimizers._constrained_sr3 import ConstrainedSR3 as _ConstrainedSR3 -from scipy.optimize import minimize -from sklearn.gaussian_process import GaussianProcessRegressor -from sklearn.gaussian_process.kernels import Matern - -# Suppress the specific AxesWarning from pysindy after import -warnings.filterwarnings( - "ignore", message=".*axes labeled for array with.*", module="pysindy" -) - - -# Bayesian optimization helper functions -def _expected_improvement(X, X_sample, Y_sample, gpr, xi=0.01): - """Expected Improvement acquisition function for Bayesian optimization.""" - mu, sigma = gpr.predict(X, return_std=True) - mu = mu.reshape(-1, 1) - sigma = sigma.reshape(-1, 1) - - mu_sample_opt = np.max(Y_sample) - - with np.errstate(divide="warn"): - imp = mu - mu_sample_opt - xi - Z = imp / sigma - ei = imp * stats.norm.cdf(Z) + sigma * stats.norm.pdf(Z) - ei[sigma == 0.0] = 0.0 - - return ei - - -def _propose_location(acquisition, X_sample, Y_sample, gpr, bounds, n_restarts=10): - """Propose next sampling point by optimizing acquisition function.""" - dim = X_sample.shape[1] - min_val = 1 - min_x = None - - def min_obj(X): - return -acquisition(X.reshape(-1, dim), X_sample, Y_sample, gpr).flatten() - - for x0 in np.random.uniform(bounds[:, 0], bounds[:, 1], size=(n_restarts, dim)): - res = minimize(min_obj, x0=x0, bounds=bounds, method="L-BFGS-B") - if res.fun < min_val: - min_val = res.fun - min_x = res.x - - return min_x.reshape(-1, 1) - - -# ============================================================================= -# Transform Cache - memoizes single-input gamma transforms to avoid recomputation -# ============================================================================= - - -class TransformCache: - """LRU cache for gamma-transformed time series. - - Caches results of convolving a forcing series with a gamma PDF kernel. - Keys are quantized (input_name, shape, scale, loc) tuples so near-identical - parameter sets reuse cached results. - """ - - def __init__(self, max_entries: int = 2000, quantization: float = 1e-6): - self._cache: "OrderedDict[tuple, np.ndarray]" = OrderedDict() - self.max_entries = max_entries - self.quantization = quantization - self.hits = 0 - self.misses = 0 - - def _quantize(self, value: float) -> float: - """Quantize a float to reduce near-duplicate keys.""" - if self.quantization <= 0: - return value - return round(value / self.quantization) * self.quantization - - def _make_key( - self, input_name: str, n: int, shape: float, scale: float, loc: float - ) -> tuple: - """Create a hashable cache key from input name and gamma params.""" - return ( - input_name, - n, - self._quantize(shape), - self._quantize(scale), - self._quantize(loc), - ) - - def get( - self, - input_name: str, - forcing_values: np.ndarray, - shape: float, - scale: float, - loc: float, - ) -> np.ndarray: - """Get cached transform or compute and cache it. - - Returns a COPY of the cached array to prevent mutation issues. - """ - n = len(forcing_values) - key = self._make_key(input_name, n, shape, scale, loc) - - if key in self._cache: - self.hits += 1 - # Move to end (most recently used) - self._cache.move_to_end(key) - return self._cache[key].copy() - - # Cache miss - compute the transform using FFT convolution - self.misses += 1 - shape_time = np.arange(0, n, 1) - gamma_kernel = stats.gamma.pdf(shape_time, shape, scale=scale, loc=loc) - result = signal.fftconvolve(forcing_values, gamma_kernel, mode="full")[:n] - - # Store in cache - self._cache[key] = result - - # Evict oldest if over capacity - if len(self._cache) > self.max_entries: - self._cache.popitem(last=False) - - return result.copy() # type: ignore[no-any-return] - - def clear(self): - """Clear the cache and reset counters.""" - self._cache.clear() - self.hits = 0 - self.misses = 0 - - def stats(self) -> dict: - """Return cache statistics.""" - total = self.hits + self.misses - hit_rate = self.hits / total if total > 0 else 0.0 - return { - "hits": self.hits, - "misses": self.misses, - "total": total, - "hit_rate": hit_rate, - "size": len(self._cache), - "max_entries": self.max_entries, - } - - def __repr__(self): - s = self.stats() - return f"TransformCache(hits={s['hits']}, misses={s['misses']}, hit_rate={s['hit_rate']:.2%}, size={s['size']})" - - -# Global cache instance used throughout the module -_transform_cache = TransformCache(max_entries=2000, quantization=1e-6) - - -# delay model builds differential equations relating the dependent variables to transformations of all the variables -# if there are no independent variables, then dependent_columns should be a list of all the columns in the dataframe -# and independent_columns should be an empty list -# by default, only the independent variables are transformed, but if transform_dependent is set to True, then the dependent variables are also transformed -# REQUIRES: -# a pandas dataframe, -# the column names of the dependent and indepdent variables, -# the number of timesteps to "wind up" the latent states, -# the initial number of transformations to use in the optimization, -# the maximum number of transformations to use in the optimization, -# the maximum number of iterations to use in the optimization -# and the order of the polynomial to use in the optimization -# bibo_stable: if true, the highest order output autocorrelation term is constrained to be negative -# RETURNS: -# models for each number of transformations from min to max -# NOTE: this code works for MIMO models, however, if output variables are dependent on each other -# poor simulation fidelity is likely due to their errors contributing to each other -# if the learned dynamics are highly accurate such that errors do not grow too large in any dependent variable, a MIMO model should work fine -# if you anticipate significant errors in the simulation of any dependent variable, you should use multiple MISO models instead -# as the model predicts derivatives, system_data must represent a *causal* system -# that is, forcing and the response to that forcing cannot occur at the same timestep -# it may be necessary for the user to shift the forcing data back to make the system causal (especially for time aggregated data like daily rainfall-runoff) -# forcing_coef_constraints is a dictionary of column name and then a 1, 0, or -1 depending on whether the coefficients of that variable should be positive, unconstrained, or negative - - -def _run_scipy_optimizer( - optimization_method: str, - objective_function, - bounds: np.ndarray, - max_iter: int, - verbose: bool, - optimizer_kwargs: dict, -) -> np.ndarray: - """ - Dispatch to scipy.optimize methods for global optimization. - - Supports any scipy.optimize method that accepts (objective, bounds, **kwargs). - Common methods: 'differential_evolution', 'dual_annealing', 'simulated_annealing', - 'basinhopping', 'shgo', 'direct', 'brute'. - - Args: - optimization_method: Name of scipy.optimize method to use - objective_function: Callable that takes parameter vector and returns scalar to minimize - bounds: Array of [min, max] bounds for each parameter - max_iter: Maximum iterations (used as default for methods that support it) - verbose: Whether to print progress - optimizer_kwargs: Additional keyword arguments passed to the optimizer - - Returns: - Best parameter vector found - """ - import scipy.optimize as opt - - # Default parameters for each method - method_defaults = { - "differential_evolution": { - "maxiter": max_iter, - "popsize": 15, - "mutation": (0.5, 1.5), - "recombination": 0.7, - "seed": 42, - "updating": "deferred", - }, - "dual_annealing": { - "maxiter": max_iter * 4, # DA needs more iterations for good exploration - "seed": 42, - "no_local_search": False, - }, - "simulated_annealing": { - "maxiter": max_iter * 4, - "seed": 42, - }, - "direct": { - "maxiter": max_iter, - "eps": 1e-4, - }, - "brute": { - "Ns": 20, - }, - } - - # Get defaults for this method, or empty dict if unknown - defaults = cast(dict[str, Any], method_defaults.get(optimization_method, {})) - - # Merge defaults with user-provided kwargs (user kwargs take precedence) - params = {**defaults, **optimizer_kwargs} - - # Get the optimizer function - optimizer = getattr(opt, optimization_method, None) - if optimizer is None: - raise ValueError( - f"Unknown optimization_method: '{optimization_method}'. " - f"Supported scipy.optimize methods: {list(method_defaults.keys())}, " - f"or 'bayesian' for built-in Bayesian optimization." - ) - - if verbose: - print(f" Running scipy.optimize.{optimization_method} with params: {params}") - - # Run the optimizer - result = optimizer(objective_function, bounds, **params) - - if verbose: - print( - f" Optimization complete. Success: {result.success}, Message: {result.message}" - ) - print(f" Best value: {-result.fun:.6f} (R²)") - - return result.x # type: ignore[no-any-return] - - -def delay_io_train( - system_data, - dependent_columns, - independent_columns, - windup_timesteps=0, - init_transforms=1, - max_transforms=4, - max_iter=250, - poly_order=3, - transform_dependent=False, - verbose=False, - extra_verbose=False, - include_bias=False, - include_interaction=False, - bibo_stable=False, - transform_only=None, - forcing_coef_constraints=None, - early_stopping_threshold=0.005, - optimization_method="bayesian", - **optimizer_kwargs, -): - forcing = system_data[independent_columns].copy(deep=True) - - orig_forcing_columns = forcing.columns - response = system_data[dependent_columns].copy(deep=True) - - results = dict() # to store the optimized models for each number of transformations - prev_model = ( - None # will hold the initial model for the current number of transforms - ) - - if transform_dependent: - shape_factors = pd.DataFrame( - columns=system_data.columns, - index=range(init_transforms, max_transforms + 1), - ) - shape_factors.iloc[0, :] = 1 # first transformation is [1,1,0] for each input - scale_factors = pd.DataFrame( - columns=system_data.columns, - index=range(init_transforms, max_transforms + 1), - ) - scale_factors.iloc[0, :] = 1 # first transformation is [1,1,0] for each input - loc_factors = pd.DataFrame( - columns=system_data.columns, - index=range(init_transforms, max_transforms + 1), - ) - loc_factors.iloc[0, :] = 0 # first transformation is [1,1,0] for each input - elif transform_only is not None: # the user provided a list of columns to transform - shape_factors = pd.DataFrame( - columns=transform_only, index=range(init_transforms, max_transforms + 1) - ) - shape_factors.iloc[0, :] = 1 # first transformation is [1,1,0] for each input - scale_factors = pd.DataFrame( - columns=transform_only, index=range(init_transforms, max_transforms + 1) - ) - scale_factors.iloc[0, :] = 1 # first transformation is [1,1,0] for each input - loc_factors = pd.DataFrame( - columns=transform_only, index=range(init_transforms, max_transforms + 1) - ) - loc_factors.iloc[0, :] = 0 # first transformation is [1,1,0] for each input - else: - # the transformation factors should be pandas dataframes where the index is which transformation it is and the columns are the variables - shape_factors = pd.DataFrame( - columns=forcing.columns, index=range(init_transforms, max_transforms + 1) - ) - shape_factors.iloc[0, :] = 1 # first transformation is [1,1,0] for each input - scale_factors = pd.DataFrame( - columns=forcing.columns, index=range(init_transforms, max_transforms + 1) - ) - scale_factors.iloc[0, :] = 1 # first transformation is [1,1,0] for each input - loc_factors = pd.DataFrame( - columns=forcing.columns, index=range(init_transforms, max_transforms + 1) - ) - loc_factors.iloc[0, :] = 0 # first transformation is [1,1,0] for each input - # print(shape_factors) - # print(scale_factors) - # print(loc_factors) - # first transformation is [1,1,0] for each input - """ - shape_factors = np.ones(shape=(forcing.shape[1] , init_transforms) ) - scale_factors = np.ones(shape=(forcing.shape[1] , init_transforms) ) - loc_factors = np.zeros(shape=(forcing.shape[1] , init_transforms) ) - """ - # speeds = list([500,200,50,10, 5,2, 1.1, 1.05,1.01]) - speeds = list( - [100, 50, 20, 10, 5, 2, 1.1, 1.05, 1.01] - ) # I don't have a great idea of what good values for these are yet - if transform_dependent: # just trying something - improvement_threshold = ( - 1.001 # when improvements are tiny, tighten up the jumps - ) - else: - improvement_threshold = 1.0 - - for num_transforms in range(init_transforms, max_transforms + 1): - print("num_transforms") - print(num_transforms) - speed_idx = 0 - speed = speeds[speed_idx] - - if not num_transforms == init_transforms: # if we're not starting right now - # start dull - shape_factors.iloc[num_transforms - 1, :] = 10 * ( - num_transforms - 1 - ) # start with a broad peak centered at ten timesteps - scale_factors.iloc[num_transforms - 1, :] = 1 - loc_factors.iloc[num_transforms - 1, :] = 0 - if verbose: - print( - "starting factors for additional transformation\nshape\nscale\nlocation" - ) - print(shape_factors) - print(scale_factors) - print(loc_factors) - - # Choose optimization method - if optimization_method == "bayesian": - if verbose: - print(f"Using Bayesian optimization for {num_transforms} transforms...") - - # Determine which columns to transform - if transform_dependent: - transform_columns = system_data.columns.tolist() - elif transform_only is not None: - transform_columns = transform_only - else: - transform_columns = independent_columns - - # Bayesian optimization for this number of transforms - bounds_list: list[list[float]] = [] - for transform in range(1, num_transforms + 1): - for col in transform_columns: - bounds_list.append([1.0, 50.0]) # shape_factors bounds - bounds_list.append([0.1, 5.0]) # scale_factors bounds - bounds_list.append([0.0, 20.0]) # loc_factors bounds - bounds = np.array(bounds_list) - - def objective_function(params_vector): - try: - # Convert vector to DataFrames - shape_factors_opt = pd.DataFrame( - columns=transform_columns, index=range(1, num_transforms + 1) - ) - scale_factors_opt = pd.DataFrame( - columns=transform_columns, index=range(1, num_transforms + 1) - ) - loc_factors_opt = pd.DataFrame( - columns=transform_columns, index=range(1, num_transforms + 1) - ) - - idx = 0 - for transform in range(1, num_transforms + 1): - for col in transform_columns: - shape_factors_opt.loc[transform, col] = params_vector[idx] - scale_factors_opt.loc[transform, col] = params_vector[ - idx + 1 - ] - loc_factors_opt.loc[transform, col] = params_vector[idx + 2] - idx += 3 - - result = SINDY_delays_MI( - shape_factors_opt, - scale_factors_opt, - loc_factors_opt, - system_data.index, - forcing, - response, - False, - poly_order, - include_bias, - include_interaction, - windup_timesteps, - bibo_stable, - transform_dependent, - transform_only, - forcing_coef_constraints, - transform_cache=_transform_cache, - ) - - r2 = result["error_metrics"]["r2"] - if verbose: - print(f" R² = {r2:.6f}") - return r2 - except Exception as e: - if verbose: - print(f" Evaluation failed: {e}") - return -1.0 - - # Bayesian optimization - # Use more iterations for Bayesian optimization to build a good surrogate model - # Cap at 200 to avoid memory issues with GP fitting - bayesian_max_iter = min(max_iter * 4, 200) - n_initial = min(20, max(10, bayesian_max_iter // 4)) - X_sample_list: list[Any] = [] - Y_sample_list: list[Any] = [] - - # Generate initial random samples - for i in range(n_initial): - x = np.random.uniform(bounds[:, 0], bounds[:, 1]) - y = objective_function(x) - X_sample_list.append(x) - Y_sample_list.append(y) - if verbose: - print(f" Initial sample {i+1}/{n_initial}: R² = {y:.6f}") - - X_sample: np.ndarray = np.array(X_sample_list) - Y_sample: np.ndarray = np.array(Y_sample_list).reshape(-1, 1) - - # Main Bayesian optimization loop - best_r2 = np.max(Y_sample) - best_params: np.ndarray = X_sample[np.argmax(Y_sample)] - - # Gaussian Process setup - kernel = Matern(length_scale=1.0, nu=2.5) - gpr = GaussianProcessRegressor( - kernel=kernel, - alpha=1e-6, - normalize_y=True, - n_restarts_optimizer=5, - random_state=42, - ) - - for iteration in range(bayesian_max_iter - n_initial): - # Fit GP and find next point - gpr.fit(X_sample, Y_sample.ravel()) - next_x = _propose_location( - _expected_improvement, X_sample, Y_sample, gpr, bounds - ) - next_x = next_x.flatten() - - # Evaluate objective - next_y = objective_function(next_x) - - if verbose: - print( - f" BO iteration {iteration+1}/{bayesian_max_iter-n_initial}: R² = {next_y:.6f}" - ) - - # Update samples - X_sample = np.append(X_sample, [next_x], axis=0) - Y_sample = np.append(Y_sample, next_y) - - # Update best - if next_y > best_r2: - best_r2 = next_y - best_params = next_x - if verbose: - print(f" New best R² = {best_r2:.6f}") - - # Convert best parameters back to DataFrames - idx = 0 - for transform in range(1, num_transforms + 1): - for col in transform_columns: - shape_factors.loc[transform, col] = best_params[idx] - scale_factors.loc[transform, col] = best_params[idx + 1] - loc_factors.loc[transform, col] = best_params[idx + 2] - idx += 3 - - # Use the optimized parameters for final evaluation - prev_model = SINDY_delays_MI( - shape_factors, - scale_factors, - loc_factors, - system_data.index, - forcing, - response, - extra_verbose, - poly_order, - include_bias, - include_interaction, - windup_timesteps, - bibo_stable, - transform_dependent=transform_dependent, - transform_only=transform_only, - forcing_coef_constraints=forcing_coef_constraints, - transform_cache=_transform_cache, - ) - - else: - # Use scipy.optimize for all other methods (differential_evolution, dual_annealing, - # basinhopping, shgo, direct, etc.) - if verbose: - print( - f"Using {optimization_method} optimization for {num_transforms} transforms..." - ) - - # Determine which columns to transform - if transform_dependent: - transform_columns = system_data.columns.tolist() - elif transform_only is not None: - transform_columns = transform_only - else: - transform_columns = independent_columns - - # Define parameter bounds for this number of transforms - bounds_list: list[list[float]] = [] # type: ignore[no-redef] - for transform in range(1, num_transforms + 1): - for col in transform_columns: - bounds_list.append([1.0, 50.0]) # shape_factors bounds - bounds_list.append([0.1, 5.0]) # scale_factors bounds - bounds_list.append([0.0, 20.0]) # loc_factors bounds - bounds = np.array(bounds_list) - - def objective_function(params_vector): - try: - # Convert vector to DataFrames - shape_factors_opt = pd.DataFrame( - columns=transform_columns, index=range(1, num_transforms + 1) - ) - scale_factors_opt = pd.DataFrame( - columns=transform_columns, index=range(1, num_transforms + 1) - ) - loc_factors_opt = pd.DataFrame( - columns=transform_columns, index=range(1, num_transforms + 1) - ) - - idx = 0 - for transform in range(1, num_transforms + 1): - for col in transform_columns: - shape_factors_opt.loc[transform, col] = params_vector[idx] - scale_factors_opt.loc[transform, col] = params_vector[ - idx + 1 - ] - loc_factors_opt.loc[transform, col] = params_vector[idx + 2] - idx += 3 - - result = SINDY_delays_MI( - shape_factors_opt, - scale_factors_opt, - loc_factors_opt, - system_data.index, - forcing, - response, - False, - poly_order, - include_bias, - include_interaction, - windup_timesteps, - bibo_stable, - transform_dependent=transform_dependent, - transform_only=transform_only, - forcing_coef_constraints=forcing_coef_constraints, - transform_cache=_transform_cache, - ) - - r2 = result["error_metrics"]["r2"] - if verbose: - print(f" R² = {r2:.6f}") - return -r2 # Minimize negative R² (maximize R²) - except Exception as e: - if verbose: - print(f" Evaluation failed: {e}") - return 1.0 # Poor score for failed evaluations - - # Dispatch to scipy.optimize method - best_params = _run_scipy_optimizer( - optimization_method=optimization_method, - objective_function=objective_function, - bounds=bounds, - max_iter=max_iter, - verbose=verbose, - optimizer_kwargs=optimizer_kwargs, - ) - - # Convert best parameters back to DataFrames - idx = 0 - for transform in range(1, num_transforms + 1): - for col in transform_columns: - shape_factors.loc[transform, col] = best_params[idx] - scale_factors.loc[transform, col] = best_params[idx + 1] - loc_factors.loc[transform, col] = best_params[idx + 2] - idx += 3 - - # Use the optimized parameters for final evaluation - prev_model = SINDY_delays_MI( - shape_factors, - scale_factors, - loc_factors, - system_data.index, - forcing, - response, - extra_verbose, - poly_order, - include_bias, - include_interaction, - windup_timesteps, - bibo_stable, - transform_dependent=transform_dependent, - transform_only=transform_only, - forcing_coef_constraints=forcing_coef_constraints, - transform_cache=_transform_cache, - ) - - # For bayesian and scipy.optimize methods, we're done with optimization - print("\nOptimization complete. Using optimized parameters for final model.") - final_model = SINDY_delays_MI( - shape_factors, - scale_factors, - loc_factors, - system_data.index, - forcing, - response, - True, - poly_order, - include_bias, - include_interaction, - windup_timesteps, - bibo_stable, - transform_dependent=transform_dependent, - transform_only=transform_only, - forcing_coef_constraints=forcing_coef_constraints, - transform_cache=_transform_cache, - ) - print("\nFinal model:\n") - try: - print(final_model["model"].print(precision=5)) - except Exception as e: - print(e) - print("R^2") - print(prev_model["error_metrics"]["r2"]) - print("shape factors") - print(shape_factors) - print("scale factors") - print(scale_factors) - print("location factors") - print(loc_factors) - print("\n") - results[num_transforms] = { - "final_model": final_model.copy(), - "shape_factors": shape_factors.copy(deep=True), - "scale_factors": scale_factors.copy(deep=True), - "loc_factors": loc_factors.copy(deep=True), - "windup_timesteps": windup_timesteps, - "dependent_columns": dependent_columns, - "independent_columns": independent_columns, - "transform_cache": _transform_cache, - } - - # check if the benefit from adding the last transformation is less than the early stopping threshold - if ( - num_transforms > init_transforms - and results[num_transforms]["final_model"]["error_metrics"]["r2"] - - results[num_transforms - 1]["final_model"]["error_metrics"]["r2"] - < early_stopping_threshold - ): - print( - "Last transformation added less than ", - early_stopping_threshold * 100, - " % to R2 score. Terminating early.", - ) - break - continue - - print("\nInitial model:\n") - try: - print(prev_model["model"].print(precision=5)) - print("R^2") - print(prev_model["error_metrics"]["r2"]) - except Exception as e: # and print the exception: - print(e) - pass - print("shape factors") - print(shape_factors) - print("scale factors") - print(scale_factors) - print("location factors") - print(loc_factors) - print("\n") - - if not verbose: - print("training ", end="") - - # no_improvement_last_time = False - for iterations in range(0, max_iter): - if not verbose and iterations % 5 == 0: - print(str(iterations) + ".", end="") - - if transform_dependent: - tuning_input = system_data.columns[ - (iterations // num_transforms) % len(system_data.columns) - ] # row = iter // width % height] - elif transform_only is not None: - tuning_input = transform_only[ - (iterations // num_transforms) % len(transform_only) - ] - else: - tuning_input = orig_forcing_columns[ - (iterations // num_transforms) % len(orig_forcing_columns) - ] # row = iter // width % height - tuning_line = ( - iterations % num_transforms + 1 - ) # col = % width (plus one because there's no zeroth transformation) - if verbose: - print( - str( - "tuning input: {i} | tuning transformation: {l:g}".format( - i=tuning_input, l=tuning_line - ) - ) - ) - - sooner_locs = loc_factors.copy(deep=True) - # sooner_locs[tuning_input][tuning_line] = float(loc_factors[tuning_input][tuning_line] - speed/10 ) - sooner_locs.loc[tuning_line, tuning_input] = float( - loc_factors.loc[tuning_line, tuning_input] - speed / 10 - ) - if sooner_locs[tuning_input][tuning_line] < 0: - sooner = {"error_metrics": {"r2": -1}} - else: - sooner = SINDY_delays_MI( - shape_factors, - scale_factors, - sooner_locs, - system_data.index, - forcing, - response, - extra_verbose, - poly_order, - include_bias, - include_interaction, - windup_timesteps, - bibo_stable, - transform_dependent=transform_dependent, - transform_only=transform_only, - forcing_coef_constraints=forcing_coef_constraints, - transform_cache=_transform_cache, - ) - - later_locs = loc_factors.copy(deep=True) - # later_locs[tuning_input][tuning_line] = float ( loc_factors[tuning_input][tuning_line] + 1.01*speed/10 ) - later_locs.loc[tuning_line, tuning_input] = float( - loc_factors.loc[tuning_line, tuning_input] + 1.01 * speed / 10 - ) - later = SINDY_delays_MI( - shape_factors, - scale_factors, - later_locs, - system_data.index, - forcing, - response, - extra_verbose, - poly_order, - include_bias, - include_interaction, - windup_timesteps, - bibo_stable, - transform_dependent=transform_dependent, - transform_only=transform_only, - forcing_coef_constraints=forcing_coef_constraints, - transform_cache=_transform_cache, - ) - - shape_up = shape_factors.copy(deep=True) - # shape_up[tuning_input][tuning_line] = float ( shape_factors[tuning_input][tuning_line]*speed*1.01 ) - shape_up.loc[tuning_line, tuning_input] = float( - shape_factors.loc[tuning_line, tuning_input] * speed * 1.01 - ) - shape_upped = SINDY_delays_MI( - shape_up, - scale_factors, - loc_factors, - system_data.index, - forcing, - response, - extra_verbose, - poly_order, - include_bias, - include_interaction, - windup_timesteps, - bibo_stable, - transform_dependent=transform_dependent, - transform_only=transform_only, - forcing_coef_constraints=forcing_coef_constraints, - transform_cache=_transform_cache, - ) - - shape_down = shape_factors.copy(deep=True) - # shape_down[tuning_input][tuning_line] = float ( shape_factors[tuning_input][tuning_line]/speed ) - shape_down.loc[tuning_line, tuning_input] = float( - shape_factors.loc[tuning_line, tuning_input] / speed - ) - if shape_down[tuning_input][tuning_line] < 1: - shape_downed = { - "error_metrics": {"r2": -1} - } # return a score of negative one as this is illegal - else: - shape_downed = SINDY_delays_MI( - shape_down, - scale_factors, - loc_factors, - system_data.index, - forcing, - response, - extra_verbose, - poly_order, - include_bias, - include_interaction, - windup_timesteps, - bibo_stable, - transform_dependent=transform_dependent, - transform_only=transform_only, - forcing_coef_constraints=forcing_coef_constraints, - transform_cache=_transform_cache, - ) - - scale_up = scale_factors.copy(deep=True) - # scale_up[tuning_input][tuning_line] = float( scale_factors[tuning_input][tuning_line]*speed*1.01 ) - scale_up.loc[tuning_line, tuning_input] = float( - scale_factors.loc[tuning_line, tuning_input] * speed * 1.01 - ) - scaled_up = SINDY_delays_MI( - shape_factors, - scale_up, - loc_factors, - system_data.index, - forcing, - response, - extra_verbose, - poly_order, - include_bias, - include_interaction, - windup_timesteps, - bibo_stable, - transform_dependent=transform_dependent, - transform_only=transform_only, - forcing_coef_constraints=forcing_coef_constraints, - transform_cache=_transform_cache, - ) - - scale_down = scale_factors.copy(deep=True) - # scale_down[tuning_input][tuning_line] = float ( scale_factors[tuning_input][tuning_line]/speed ) - scale_down.loc[tuning_line, tuning_input] = float( - scale_factors.loc[tuning_line, tuning_input] / speed - ) - scaled_down = SINDY_delays_MI( - shape_factors, - scale_down, - loc_factors, - system_data.index, - forcing, - response, - extra_verbose, - poly_order, - include_bias, - include_interaction, - windup_timesteps, - bibo_stable, - transform_dependent=transform_dependent, - transform_only=transform_only, - forcing_coef_constraints=forcing_coef_constraints, - transform_cache=_transform_cache, - ) - - # rounder - rounder_shape = shape_factors.copy(deep=True) - # rounder_shape[tuning_input][tuning_line] = shape_factors[tuning_input][tuning_line]*(speed*1.01) - rounder_shape.loc[tuning_line, tuning_input] = shape_factors.loc[ - tuning_line, tuning_input - ] * (speed * 1.01) - rounder_scale = scale_factors.copy(deep=True) - # rounder_scale[tuning_input][tuning_line] = scale_factors[tuning_input][tuning_line]/(speed*1.01) - rounder_scale.loc[tuning_line, tuning_input] = scale_factors.loc[ - tuning_line, tuning_input - ] / (speed * 1.01) - rounder = SINDY_delays_MI( - rounder_shape, - rounder_scale, - loc_factors, - system_data.index, - forcing, - response, - extra_verbose, - poly_order, - include_bias, - include_interaction, - windup_timesteps, - bibo_stable, - transform_dependent=transform_dependent, - transform_only=transform_only, - forcing_coef_constraints=forcing_coef_constraints, - transform_cache=_transform_cache, - ) - - # sharper - sharper_shape = shape_factors.copy(deep=True) - # sharper_shape[tuning_input][tuning_line] = shape_factors[tuning_input][tuning_line]/speed - sharper_shape.loc[tuning_line, tuning_input] = ( - shape_factors.loc[tuning_line, tuning_input] / speed - ) - if sharper_shape[tuning_input][tuning_line] < 1: - sharper = { - "error_metrics": {"r2": -1} - } # lower bound on shape to avoid inf - else: - sharper_scale = scale_factors.copy(deep=True) - # sharper_scale[tuning_input][tuning_line] = scale_factors[tuning_input][tuning_line]*speed - sharper_scale.loc[tuning_line, tuning_input] = ( - scale_factors.loc[tuning_line, tuning_input] * speed - ) - sharper = SINDY_delays_MI( - sharper_shape, - sharper_scale, - loc_factors, - system_data.index, - forcing, - response, - extra_verbose, - poly_order, - include_bias, - include_interaction, - windup_timesteps, - bibo_stable, - transform_dependent=transform_dependent, - transform_only=transform_only, - forcing_coef_constraints=forcing_coef_constraints, - transform_cache=_transform_cache, - ) - - scores = [ - prev_model["error_metrics"]["r2"], - shape_upped["error_metrics"]["r2"], - shape_downed["error_metrics"]["r2"], - scaled_up["error_metrics"]["r2"], - scaled_down["error_metrics"]["r2"], - sooner["error_metrics"]["r2"], - later["error_metrics"]["r2"], - rounder["error_metrics"]["r2"], - sharper["error_metrics"]["r2"], - ] - # print(scores) - - if ( - sooner["error_metrics"]["r2"] >= max(scores) - and sooner["error_metrics"]["r2"] - > improvement_threshold * prev_model["error_metrics"]["r2"] - ): - prev_model = sooner.copy() - loc_factors = sooner_locs.copy(deep=True) - elif ( - later["error_metrics"]["r2"] >= max(scores) - and later["error_metrics"]["r2"] - > improvement_threshold * prev_model["error_metrics"]["r2"] - ): - prev_model = later.copy() - loc_factors = later_locs.copy(deep=True) - elif ( - shape_upped["error_metrics"]["r2"] >= max(scores) - and shape_upped["error_metrics"]["r2"] - > improvement_threshold * prev_model["error_metrics"]["r2"] - ): - prev_model = shape_upped.copy() - shape_factors = shape_up.copy(deep=True) - elif ( - shape_downed["error_metrics"]["r2"] >= max(scores) - and shape_downed["error_metrics"]["r2"] - > improvement_threshold * prev_model["error_metrics"]["r2"] - ): - prev_model = shape_downed.copy() - shape_factors = shape_down.copy(deep=True) - elif ( - scaled_up["error_metrics"]["r2"] >= max(scores) - and scaled_up["error_metrics"]["r2"] - > improvement_threshold * prev_model["error_metrics"]["r2"] - ): - prev_model = scaled_up.copy() - scale_factors = scale_up.copy(deep=True) - elif ( - scaled_down["error_metrics"]["r2"] >= max(scores) - and scaled_down["error_metrics"]["r2"] - > improvement_threshold * prev_model["error_metrics"]["r2"] - ): - prev_model = scaled_down.copy() - scale_factors = scale_down.copy(deep=True) - elif ( - rounder["error_metrics"]["r2"] >= max(scores) - and rounder["error_metrics"]["r2"] - > improvement_threshold * prev_model["error_metrics"]["r2"] - ): - prev_model = rounder.copy() - shape_factors = rounder_shape.copy(deep=True) - scale_factors = rounder_scale.copy(deep=True) - elif ( - sharper["error_metrics"]["r2"] >= max(scores) - and sharper["error_metrics"]["r2"] - > improvement_threshold * prev_model["error_metrics"]["r2"] - ): - prev_model = sharper.copy() - shape_factors = sharper_shape.copy(deep=True) - scale_factors = sharper_scale.copy(deep=True) - # the middle was best, but it's bad, tighten up the bounds (if we're at the last tuning line of the last input) - - elif ( - num_transforms == tuning_line - and tuning_input == shape_factors.columns[-1] - ): # no improvement transforming last column - # no_improvement_last_time=True - speed_idx = speed_idx + 1 - if verbose: - print("\n\ntightening bounds\n\n") - """ - elif (num_transforms == tuning_line and tuning_input == orig_forcing_columns[0] and no_improvement_last_time): # no improvement next iteration (first column) - speed_idx = speed_idx + 1 - no_improvement_last_time=False - if verbose: - print("\n\ntightening bounds\n\n") - """ - - if speed_idx >= len(speeds): - print("\n\noptimization complete\n\n") - break - speed = speeds[speed_idx] - if verbose: - print( - "\nprevious, shape up, shape down, scale up, scale down, sooner, later, rounder, sharper" - ) - print(scores) - print("speed") - print(speed) - print("shape factors") - print(shape_factors) - print("scale factors") - print(scale_factors) - print("location factors") - print(loc_factors) - print("iteration no:") - print(iterations) - print("model") - try: - prev_model["model"].print(precision=5) - except Exception as e: - print(e) - print("\n") - - final_model = SINDY_delays_MI( - shape_factors, - scale_factors, - loc_factors, - system_data.index, - forcing, - response, - True, - poly_order, - include_bias, - include_interaction, - windup_timesteps, - bibo_stable, - transform_dependent=transform_dependent, - transform_only=transform_only, - forcing_coef_constraints=forcing_coef_constraints, - transform_cache=_transform_cache, - ) - print("\nFinal model:\n") - try: - print(final_model["model"].print(precision=5)) - except Exception as e: - print(e) - print("R^2") - print(prev_model["error_metrics"]["r2"]) - print("shape factors") - print(shape_factors) - print("scale factors") - print(scale_factors) - print("location factors") - print(loc_factors) - print("\n") - results[num_transforms] = { - "final_model": final_model.copy(), - "shape_factors": shape_factors.copy(deep=True), - "scale_factors": scale_factors.copy(deep=True), - "loc_factors": loc_factors.copy(deep=True), - "windup_timesteps": windup_timesteps, - "dependent_columns": dependent_columns, - "independent_columns": independent_columns, - "transform_cache": _transform_cache, - } - - # check if the benefit from adding the last transformation is less than the early stopping threshold - if ( - num_transforms > init_transforms - and results[num_transforms]["final_model"]["error_metrics"]["r2"] - - results[num_transforms - 1]["final_model"]["error_metrics"]["r2"] - < early_stopping_threshold - ): - print( - "Last transformation added less than ", - early_stopping_threshold * 100, - " % to R2 score. Terminating early.", - ) - break - - return results - - -def SINDY_delays_MI( - shape_factors, - scale_factors, - loc_factors, - index, - forcing, - response, - final_run, - poly_degree, - include_bias, - include_interaction, - windup_timesteps, - bibo_stable=False, - transform_dependent=False, - transform_only=None, - forcing_coef_constraints=None, - transform_cache=None, -): - if transform_only is not None: - transformed_forcing = transform_inputs( - shape_factors, - scale_factors, - loc_factors, - index, - forcing.loc[:, transform_only], - cache=transform_cache, - ) - untransformed_forcing = forcing.drop(columns=transform_only) - # combine forcing and transformed forcing column-wise - forcing = pd.concat( - (untransformed_forcing, transformed_forcing), axis="columns" - ) - else: - forcing = transform_inputs( - shape_factors, - scale_factors, - loc_factors, - index, - forcing, - cache=transform_cache, - ) - - feature_names = response.columns.tolist() + forcing.columns.tolist() - - # SINDy - if ( - not bibo_stable and forcing_coef_constraints is None - ): # no constraints, normal mode - model = ps.SINDy( - differentiation_method=ps.FiniteDifference(), - feature_library=ps.PolynomialLibrary( - degree=poly_degree, - include_bias=include_bias, - include_interaction=include_interaction, - ), - optimizer=ps.STLSQ(threshold=0), - ) - elif forcing_coef_constraints is not None and not bibo_stable: - library = ps.PolynomialLibrary( - degree=poly_degree, - include_bias=include_bias, - include_interaction=include_interaction, - ) - total_train = pd.concat((response, forcing), axis="columns") - library.fit([ps.AxesArray(total_train, {"ax_sample": 0, "ax_coord": 1})]) - n_features = library.n_output_features_ - n_targets = len(response.columns) - constraint_rhs = np.zeros((n_features,)) # every feature is constrained - # one row per constraint, one column per coefficient - constraint_lhs = np.zeros((n_features, n_targets * n_features)) - - # now implement the forcing coefficient constraints - for i, col in enumerate(feature_names): - for key in forcing_coef_constraints.keys(): - if key in col: - constraint_lhs[i, i] = -forcing_coef_constraints[key] - # invert the sign because the eqn is written as "leq 0" - - model = ps.SINDy( - differentiation_method=ps.FiniteDifference(), - feature_library=ps.PolynomialLibrary( - degree=poly_degree, - include_bias=include_bias, - include_interaction=include_interaction, - ), - optimizer=_ConstrainedSR3( - reg_weight_lam=0, - regularizer="l2", - constraint_lhs=constraint_lhs, - constraint_rhs=constraint_rhs, - inequality_constraints=True, - ), - ) - elif ( - bibo_stable - ): # highest order output autocorrelation is constrained to be negative - # import cvxpy - # run_cvxpy= True - # Figure out how many library features there will be - library = ps.PolynomialLibrary( - degree=poly_degree, - include_bias=include_bias, - include_interaction=include_interaction, - ) - total_train = pd.concat((response, forcing), axis="columns") - library.fit([ps.AxesArray(total_train, {"ax_sample": 0, "ax_coord": 1})]) - n_features = library.n_output_features_ - # print(f"Features ({n_features}):", library.get_feature_names(input_features=total_train.columns)) - feature_names = library.get_feature_names(input_features=total_train.columns) - # Set constraints - n_targets = total_train.shape[ - 1 - ] # not sure what targets means after reading through the pysindy docs - # print("n_targets") - # print(n_targets) - constraint_rhs = np.zeros((len(response.columns), 1)) - # one row per constraint, one column per coefficient - constraint_lhs = np.zeros((len(response.columns), n_features)) - - # print(constraint_rhs) - # print(constraint_lhs) - # constrain the highest order output autocorrelation to be negative - # this indexing is only right for include_interaction=False, include_bias=False, and pure polynomial library - # for more complex libraries, some conditional logic will be needed to grab the right column - constraint_lhs[ - :, -len(forcing.columns) - len(response.columns) : -len(forcing.columns) - ] = 1 - # leq 0 - # print("constraint lhs") - # print(constraint_lhs) - - # forcing_coef_constraints only implemented for bibo stable MISO models right now - if forcing_coef_constraints is not None: - n_targets = len(response.columns) - constraint_rhs = np.zeros((n_features,)) # every feature is constrained - # one row per constraint, one column per coefficient - constraint_lhs = np.zeros((n_features, n_targets * n_features)) - # bibo stability, set the highest order output autocorrelation to be negative for each response variable - # the index corresponds to the last entry in "feature_names" which includes the name of the response column - highest_power_col_idx = 0 - for i, col in enumerate(feature_names): - if response.columns[0] in col: - highest_power_col_idx = i - constraint_lhs[0, highest_power_col_idx] = ( - 1 # first row, highest power of the response variable - ) - - # now implement the forcing coefficient constraints - for i, col in enumerate(feature_names): - for key in forcing_coef_constraints.keys(): - if key in col: - constraint_lhs[i, i] = -forcing_coef_constraints[key] - # invert the sign because the eqn is written as "leq 0" - """' - print(forcing.columns) - forcing_constraints_array = np.ndarray(shape=(1,len(forcing.columns))) - for i, col in enumerate(forcing.columns): - if col in forcing_coef_constraints.keys(): # invert the sign because the eqn is written as "leq 0" - forcing_constraints_array[0,i] = -forcing_coef_constraints[col] - elif str(col).replace('_tr_1','') in forcing_coef_constraints.keys(): - forcing_constraints_array[0,i] = -forcing_coef_constraints[str(col).replace('_tr_1','')] - elif str(col).replace('_tr_2','') in forcing_coef_constraints.keys(): - forcing_constraints_array[0,i] = -forcing_coef_constraints[str(col).replace('_tr_2','')] - elif str(col).replace('_tr_3','') in forcing_coef_constraints.keys(): - forcing_constraints_array[0,i] = -forcing_coef_constraints[str(col).replace('_tr_3','')] - else: - forcing_constraints_array[0,i] = 0 - - for row in range(n_targets, n_features): - constraint_lhs[row, row] = forcing_constraints_array[0,row - n_targets] - """ - - # constrain the highest order output autocorrelation to be negative - # this indexing is only right for include_interaction=False, include_bias=False, and pure polynomial library - # for more complex libraries, some conditional logic will be needed to grab the right column - # constraint_lhs[:n_targets,-len(forcing.columns)-len(response.columns):-len(forcing.columns)] = 1 - - # print(forcing_constraints_array) - - # print('constraint lhs') - # print(constraint_lhs) - # print('constraint rhs') - # print(constraint_rhs) - - model = ps.SINDy( - differentiation_method=ps.FiniteDifference(), - feature_library=ps.PolynomialLibrary( - degree=poly_degree, - include_bias=include_bias, - include_interaction=include_interaction, - ), - optimizer=_ConstrainedSR3( - reg_weight_lam=0, - regularizer="l2", - constraint_lhs=constraint_lhs, - constraint_rhs=constraint_rhs, - inequality_constraints=True, - ), - ) - if transform_dependent: - # combine response and forcing into one dataframe - total_train = pd.concat((response, forcing), axis="columns") - total_train = transform_inputs( - shape_factors, scale_factors, loc_factors, index, total_train - ) - # remove the columns in total_train that are already in response (just want to keep the transformed forcing) - total_train = total_train.drop(columns=response.columns) - feature_names = response.columns.tolist() + total_train.columns.tolist() - - # need to add constraints such that variables don't depend on their own past values (but they can have autocorrelations) - - library = ps.PolynomialLibrary( - degree=poly_degree, - include_bias=include_bias, - include_interaction=include_interaction, - ) - library_terms = pd.concat((total_train, response), axis="columns") - library.fit([ps.AxesArray(library_terms, {"ax_sample": 0, "ax_coord": 1})]) - n_features = library.n_output_features_ - # print(f"Features ({n_features}):", library.get_feature_names()) - # Set constraints - n_targets = response.shape[ - 1 - ] # not sure what targets means after reading through the pysindy docs - - constraint_rhs = np.zeros((n_targets,)) - # one row per constraint, one column per coefficient - constraint_lhs = np.zeros((n_targets, n_features * n_targets)) - # for bibo stability, starting guess is that each dependent variable is negatively autocorrelated and depends on no other variable - if bibo_stable: - initial_guess = np.zeros((n_targets, n_features)) - for idx in range(0, n_targets): - initial_guess[idx, idx] = -1 - else: - initial_guess = None - # print(constraint_rhs) - # print(constraint_lhs) - # set the coefficient on a variable's own transformed value to 0 - for idx in range(0, n_targets): - constraint_lhs[idx, (idx + 1) * n_features - n_targets + idx] = 1 - - # print("constraint lhs") - # print(constraint_lhs) - - model = ps.SINDy( - differentiation_method=ps.FiniteDifference(), - feature_library=library, - optimizer=_ConstrainedSR3( - reg_weight_lam=0, - regularizer="l0", - relax_coeff_nu=10e9, - initial_guess=initial_guess, - constraint_lhs=constraint_lhs, - constraint_rhs=constraint_rhs, - inequality_constraints=False, - max_iter=10000, - ), - ) - - try: - # windup latent states (if your windup is too long, this will error) - model.fit( - response.values[windup_timesteps:, :], - t=np.arange(0, len(index), 1)[windup_timesteps:], - u=total_train.values[windup_timesteps:, :], - ) - r2 = model.score( - response.values[windup_timesteps:, :], - t=np.arange(0, len(index), 1)[windup_timesteps:], - u=total_train.values[windup_timesteps:, :], - ) # training data score - except Exception as e: # and print the exception - print("Exception in model fitting, returning r2=-1\n") - print(e) - error_metrics = { - "MAE": [False], - "RMSE": [False], - "NSE": [False], - "alpha": [False], - "beta": [False], - "HFV": [False], - "HFV10": [False], - "LFV": [False], - "FDC": [False], - "r2": -1, - } - return { - "error_metrics": error_metrics, - "model": None, - "simulated": False, - "response": response, - "forcing": forcing, - "index": index, - "diverged": False, - } - - else: - try: - # windup latent states (if your windup is too long, this will error) - model.fit( - response.values[windup_timesteps:, :], - t=np.arange(0, len(index), 1)[windup_timesteps:], - u=forcing.values[windup_timesteps:, :], - ) - r2 = model.score( - response.values[windup_timesteps:, :], - t=np.arange(0, len(index), 1)[windup_timesteps:], - u=forcing.values[windup_timesteps:, :], - ) # training data score - except Exception as e: # and print the exception - print("Exception in model fitting, returning r2=-1\n") - print(e) - error_metrics = { - "MAE": [False], - "RMSE": [False], - "NSE": [False], - "alpha": [False], - "beta": [False], - "HFV": [False], - "HFV10": [False], - "LFV": [False], - "FDC": [False], - "r2": -1, - } - return { - "error_metrics": error_metrics, - "model": None, - "simulated": False, - "response": response, - "forcing": forcing, - "index": index, - "diverged": False, - } - # r2 is how well we're doing across all the outputs. that's actually good to keep model accuracy lumped because that's what makes most sense to drive the optimization - # even though the metrics we'll want to evaluate models on are individual output accuracy - # print("training R^2", r2) - # model.print(precision=5) - - # return false for things not evaluated / don't exist - error_metrics = { - "MAE": [False], - "RMSE": [False], - "NSE": [False], - "alpha": [False], - "beta": [False], - "HFV": [False], - "HFV10": [False], - "LFV": [False], - "FDC": [False], - "r2": r2, - } - simulated = False - if final_run: # only simulate final runs because it's slow - try: # once in high volume training put this back in, but want to see the errors during development - if transform_dependent: - simulated = model.simulate( - response.values[windup_timesteps, :], - t=np.arange(0, len(index), 1)[windup_timesteps:], - u=total_train.values[windup_timesteps:, :], - ) - else: - simulated = model.simulate( - response.values[windup_timesteps, :], - t=np.arange(0, len(index), 1)[windup_timesteps:], - u=forcing.values[windup_timesteps:, :], - ) - mae = list() - rmse = list() - nse = list() - alpha = list() - beta = list() - hfv = list() - hfv10 = list() - lfv = list() - fdc = list() - for col_idx in range( - 0, len(response.columns) - ): # univariate performance metrics - error = ( - response.values[windup_timesteps + 1 :, col_idx] - - simulated[:, col_idx] - ) - - # print("error") - # print(error) - # nash sutcliffe efficiency between response and simulated - mae.append(np.mean(np.abs(error))) - rmse.append(np.sqrt(np.mean(error**2))) - # print("mean measured = ", np.mean(response.values[windup_timesteps+1:,col_idx] )) - # print("sum of squared error between measured and model = ", np.sum((error)**2 )) - # print("sum of squared error between measured and mean of measured = ", np.sum((response.values[windup_timesteps+1:,col_idx]-np.mean(response.values[windup_timesteps+1:,col_idx] ) )**2 )) - nse.append( - 1 - - np.sum((error) ** 2) - / np.sum( - ( - response.values[windup_timesteps + 1 :, col_idx] - - np.mean(response.values[windup_timesteps + 1 :, col_idx]) - ) - ** 2 - ) - ) - alpha.append( - np.std(simulated[:, col_idx]) - / np.std(response.values[windup_timesteps + 1 :, col_idx]) - ) - beta.append( - np.mean(simulated[:, col_idx]) - / np.mean(response.values[windup_timesteps + 1 :, col_idx]) - ) - hfv.append( - 100 - * np.sum( - np.sort(simulated[:, col_idx])[-int(0.02 * len(index)) :] - - np.sort(response.values[windup_timesteps + 1 :, col_idx])[ - -int(0.02 * len(index)) : - ] - ) - / np.sum( - np.sort(response.values[windup_timesteps + 1 :, col_idx])[ - -int(0.02 * len(index)) : - ] - ) - ) - hfv10.append( - 100 - * np.sum( - np.sort(simulated[:, col_idx])[-int(0.1 * len(index)) :] - - np.sort(response.values[windup_timesteps + 1 :, col_idx])[ - -int(0.1 * len(index)) : - ] - ) - / np.sum( - np.sort(response.values[windup_timesteps + 1 :, col_idx])[ - -int(0.1 * len(index)) : - ] - ) - ) - lfv.append( - 100 - * np.sum( - np.sort(simulated[:, col_idx])[-int(0.3 * len(index)) :] - - np.sort(response.values[windup_timesteps + 1 :, col_idx])[ - -int(0.3 * len(index)) : - ] - ) - / np.sum( - np.sort(response.values[windup_timesteps + 1 :, col_idx])[ - -int(0.3 * len(index)) : - ] - ) - ) - fdc.append( - 100 - * ( - np.log10( - np.sort(simulated[:, col_idx])[int(0.2 * len(simulated))] - ) - - np.log10( - np.sort(simulated[:, col_idx])[int(0.7 * len(simulated))] - ) - - np.log10( - np.sort(response.values[windup_timesteps + 1 :, col_idx])[ - int(0.2 * len(simulated)) - ] - ) - + np.log10( - np.sort(response.values[windup_timesteps + 1 :, col_idx])[ - int(0.7 * len(simulated)) - ] - ) - ) - / np.log10( - np.sort(response.values[windup_timesteps + 1 :, col_idx])[ - int(0.2 * len(simulated)) - ] - ) - - np.log10( - np.sort(response.values[windup_timesteps + 1 :, col_idx])[ - int(0.7 * len(simulated)) - ] - ) - ) - - print("MAE = ", mae) - print("RMSE = ", rmse) - print("NSE = ", nse) - # alpha nse decomposition due to gupta et al 2009 - print("alpha = ", alpha) - print("beta = ", beta) - # top 2% peak flow bias (HFV) due to yilmaz et al 2008 - print("HFV = ", hfv) - # top 10% peak flow bias (HFV) due to yilmaz et al 2008 - print("HFV10 = ", hfv10) - # 30% low flow bias (LFV) due to yilmaz et al 2008 - print("LFV = ", lfv) - # bias of FDC midsegment slope due to yilmaz et al 2008 - print("FDC = ", fdc) - # compile all the error metrics into a dictionary - error_metrics = { - "MAE": mae, - "RMSE": rmse, - "NSE": nse, - "alpha": alpha, - "beta": beta, - "HFV": hfv, - "HFV10": hfv10, - "LFV": lfv, - "FDC": fdc, - "r2": r2, - } - - except Exception as e: # and print the exception: - print("Exception in simulation\n") - print(e) - error_metrics = { - "MAE": [np.nan], - "RMSE": [np.nan], - "NSE": [np.nan], - "alpha": [np.nan], - "beta": [np.nan], - "HFV": [np.nan], - "HFV10": [np.nan], - "LFV": [np.nan], - "FDC": [np.nan], - "r2": r2, - } - - return { - "error_metrics": error_metrics, - "model": model, - "simulated": response[1:], - "response": response, - "forcing": forcing, - "index": index, - "diverged": True, - } - - return { - "error_metrics": error_metrics, - "model": model, - "simulated": simulated, - "response": response, - "forcing": forcing, - "index": index, - "diverged": False, - } - # return [r2, model, mae, rmse, index, simulated , response , forcing] - - -def transform_inputs( - shape_factors, scale_factors, loc_factors, index, forcing, *, cache=None -): - """Vectorized implementation of transform_inputs for greater speed. - - Applies gamma PDF transformations to forcing inputs using FFT-based convolution - instead of element-wise iteration. Optional LRU cache avoids recomputation for - near-identical parameters during optimization. - - Args: - shape_factors: DataFrame of gamma shape parameters - scale_factors: DataFrame of gamma scale parameters - loc_factors: DataFrame of gamma location parameters - index: Time index - forcing: DataFrame of forcing inputs - cache: Optional TransformCache instance for memoization (default None) - """ - # original forcing columns -> columns of forcing that don't have _tr_ in their name - orig_forcing_columns = [col for col in forcing.columns if "_tr_" not in col] - - # how many rows of shape_factors do not contain NaNs? - num_transforms = int(shape_factors.count().iloc[0]) - - n = len(index) - - for input_col in orig_forcing_columns: - forcing_values = forcing[input_col].to_numpy(dtype=float) - - for transform_idx in range(1, num_transforms + 1): - col_name = f"{input_col}_tr_{transform_idx}" - - # Get gamma parameters - shape = float(shape_factors[input_col][transform_idx]) - scale = float(scale_factors[input_col][transform_idx]) - loc = float(loc_factors[input_col][transform_idx]) - - if cache is not None: - # Use cached transform - result = cache.get(input_col, forcing_values, shape, scale, loc) - else: - # Compute directly using FFT convolution (faster than np.convolve for large arrays) - shape_time = np.arange(0, n, 1) - gamma_kernel = stats.gamma.pdf(shape_time, shape, scale=scale, loc=loc) - result = signal.fftconvolve(forcing_values, gamma_kernel, mode="full")[ - :n - ] - - forcing.loc[:, col_name] = result - - # assert there are no NaNs in the forcing - if forcing.isnull().values.any(): - raise ValueError("Transform inputs produced NaN values") - return forcing - - -# REQUIRES: the output of delay_io_train, starting value of otuput, forcing timeseries -# EFFECTS: returns a simulated response given forcing and a model -# REQUIRED EDITS: not written to accomodate transform_dependent yet -def delay_io_predict( - delay_io_model, - system_data, - num_transforms=1, - evaluation=False, - windup_timesteps=None, -): - if ( - windup_timesteps is None - ): # user didn't specify windup timesteps, use what the model trained with. - windup_timesteps = delay_io_model[num_transforms]["windup_timesteps"] - forcing = system_data[delay_io_model[num_transforms]["independent_columns"]].copy( - deep=True - ) - response = system_data[delay_io_model[num_transforms]["dependent_columns"]].copy( - deep=True - ) - - # Use cache from model if available, otherwise create a new one - transform_cache = delay_io_model[num_transforms].get("transform_cache", None) - transformed_forcing = transform_inputs( - shape_factors=delay_io_model[num_transforms]["shape_factors"], - scale_factors=delay_io_model[num_transforms]["scale_factors"], - loc_factors=delay_io_model[num_transforms]["loc_factors"], - index=system_data.index, - forcing=forcing, - cache=transform_cache, - ) - try: - prediction = delay_io_model[num_transforms]["final_model"]["model"].simulate( - system_data[delay_io_model[num_transforms]["dependent_columns"]].iloc[ - windup_timesteps, : - ], - t=np.arange(0, len(system_data.index), 1)[windup_timesteps:], - u=transformed_forcing[windup_timesteps:], - ) - except Exception as e: # and print the exception: - print("Exception in simulation\n") - print(e) - print("diverged.") - error_metrics = { - "MAE": [np.nan], - "RMSE": [np.nan], - "NSE": [np.nan], - "alpha": [np.nan], - "beta": [np.nan], - "HFV": [np.nan], - "HFV10": [np.nan], - "LFV": [np.nan], - "FDC": [np.nan], - } - return { - "prediction": np.nan - * np.ones(shape=response[windup_timesteps + 1 :].shape), - "error_metrics": error_metrics, - "diverged": True, - } - - # return all the error metrics if the prediction is being evaluated against known measurements - if evaluation: - try: - mae = list() - rmse = list() - nse = list() - alpha = list() - beta = list() - hfv = list() - hfv10 = list() - lfv = list() - fdc = list() - for col_idx in range( - 0, len(response.columns) - ): # univariate performance metrics - error = ( - response.values[windup_timesteps + 1 :, col_idx] - - prediction[:, col_idx] - ) - - initial_error_length = len(error) - error = error[~np.isnan(error)] - if len(error) < 0.75 * initial_error_length: - print("WARNING: More than 25% of the entries in error were NaN") - - # print("error") - # print(error) - # nash sutcliffe efficiency between response and prediction - mae.append(np.mean(np.abs(error))) - rmse.append(np.sqrt(np.mean(error**2))) - # print("mean measured = ", np.mean(response.values[windup_timesteps+1:,col_idx] )) - # print("sum of squared error between measured and model = ", np.sum((error)**2 )) - # print("sum of squared error between measured and mean of measured = ", np.sum((response.values[windup_timesteps+1:,col_idx]-np.mean(response.values[windup_timesteps+1:,col_idx] ) )**2 )) - nse.append( - 1 - - np.sum((error) ** 2) - / np.sum( - ( - response.values[windup_timesteps + 1 :, col_idx] - - np.mean(response.values[windup_timesteps + 1 :, col_idx]) - ) - ** 2 - ) - ) - alpha.append( - np.std(prediction[:, col_idx]) - / np.std(response.values[windup_timesteps + 1 :, col_idx]) - ) - beta.append( - np.mean(prediction[:, col_idx]) - / np.mean(response.values[windup_timesteps + 1 :, col_idx]) - ) - hfv.append( - np.sum( - np.sort(prediction[:, col_idx])[ - -int(0.02 * len(system_data.index)) : - ] - ) - / np.sum( - np.sort(response.values[windup_timesteps + 1 :, col_idx])[ - -int(0.02 * len(system_data.index)) : - ] - ) - ) - hfv10.append( - np.sum( - np.sort(prediction[:, col_idx])[ - -int(0.1 * len(system_data.index)) : - ] - ) - / np.sum( - np.sort(response.values[windup_timesteps + 1 :, col_idx])[ - -int(0.1 * len(system_data.index)) : - ] - ) - ) - lfv.append( - np.sum( - np.sort(prediction[:, col_idx])[ - : int(0.3 * len(system_data.index)) - ] - ) - / np.sum( - np.sort(response.values[windup_timesteps + 1 :, col_idx])[ - : int(0.3 * len(system_data.index)) - ] - ) - ) - fdc.append( - np.mean( - np.sort(prediction[:, col_idx])[ - -int(0.6 * len(system_data.index)) : -int( - 0.4 * len(system_data.index) - ) - ] - ) - / np.mean( - np.sort(response.values[windup_timesteps + 1 :, col_idx])[ - -int(0.6 * len(system_data.index)) : -int( - 0.4 * len(system_data.index) - ) - ] - ) - ) - - print("MAE = ", mae) - print("RMSE = ", rmse) - - print("NSE = ", nse) - # alpha nse decomposition due to gupta et al 2009 - print("alpha = ", alpha) - print("beta = ", beta) - # top 2% peak flow bias (HFV) due to yilmaz et al 2008 - print("HFV = ", hfv) - # top 10% peak flow bias (HFV) due to yilmaz et al 2008 - print("HFV10 = ", hfv10) - # 30% low flow bias (LFV) due to yilmaz et al 2008 - print("LFV = ", lfv) - # bias of FDC midsegment slope due to yilmaz et al 2008 - print("FDC = ", fdc) - # compile all the error metrics into a dictionary - error_metrics = { - "MAE": mae, - "RMSE": rmse, - "NSE": nse, - "alpha": alpha, - "beta": beta, - "HFV": hfv, - "HFV10": hfv10, - "LFV": lfv, - "FDC": fdc, - } - # omit r2 here because it doesn't mean the same thing as it does for training, would be misleading. - # r2 in training expresses how much of the derivative is predicted by the model, whereas in evaluation it expresses how much of the response is predicted by the model - - return { - "prediction": prediction, - "error_metrics": error_metrics, - "diverged": False, - } - except Exception as e: # and print the exception: - print(e) - print("Simulation diverged.") - error_metrics = { - "MAE": [np.nan], - "RMSE": [np.nan], - "NSE": [np.nan], - "alpha": [np.nan], - "beta": [np.nan], - "HFV": [np.nan], - "HFV10": [np.nan], - "LFV": [np.nan], - "FDC": [np.nan], - "diverged": [True], - } - - return {"prediction": prediction, "error_metrics": error_metrics} - else: - error_metrics = { - "MAE": [np.nan], - "RMSE": [np.nan], - "NSE": [np.nan], - "alpha": [np.nan], - "beta": [np.nan], - "HFV": [np.nan], - "HFV10": [np.nan], - "LFV": [np.nan], - "FDC": [np.nan], - } - return { - "prediction": prediction, - "error_metrics": error_metrics, - "diverged": False, - } - - -### the functions below are for generating LTI systems directly from data (aka system identification) - - -# the function below returns an LTI system (in the matrices A, B, and C) that mimic the shape of a given gamma distribution -# scaling should be correct, but need to verify that -# max state dim, resolution, and max iterations could be icnrased to improve accuracy -def lti_from_gamma( - shape, - scale, - location, - dt=0, - desired_NSE=0.999, - verbose=False, - max_state_dim=50, - max_iterations=200, - max_pole_speed=5, - min_pole_speed=0.01, -): - # a pole of speed -5 decays to less than 1% of it's value after one timestep - # a pole of speed -0.01 decays to more than 99% of it's value after one timestep - - # i've assumed here that gamma pdf is defined the same as in matlab - # if that's not true testing will show it soon enough - t50 = shape * scale + location # center of mass - skewness = 2 / np.sqrt(shape) - total_time_base = ( - 2 * t50 - ) # not that this contains the full shape, but if we fit this much of the curve perfectly we'll be close enough - # resolution = (t50)/((skewness + location)) # make this coarser for faster debugging - resolution = (t50) / (10 * (skewness + location)) # production version - - # resolution = 1/ skewness - decay_rate = 1 / resolution - decay_rate = np.clip(decay_rate, min_pole_speed, max_pole_speed) - state_dim = int( - np.floor(total_time_base * decay_rate) - ) # this keeps the time base fixed for a given decay rate - if state_dim > max_state_dim: - state_dim = max_state_dim - decay_rate = state_dim / total_time_base - resolution = 1 / decay_rate - if state_dim < 1: - state_dim = 1 - decay_rate = state_dim / total_time_base - resolution = 1 / decay_rate - - decay_rate = np.clip(decay_rate, min_pole_speed, max_pole_speed) - - if verbose: - print("state dimension is ", state_dim) - print("decay rate is ", decay_rate) - print("total time base is ", total_time_base) - print("resolution is", resolution) - - # make the timestep one so that the relative error is correct (dt too small makes error bigger than written) - # t = np.linspace(0,3*total_time_base,1000) - # desired_error = desired_error / dt - """ - if dt > 0: # true if numeric - t = np.arange(0,2*total_time_base,dt) - else: - t= np.linspace(0,2*total_time_base,num=200) - """ - t = np.linspace(0, 2 * total_time_base, num=200) - - # if verbose: - # print("dt is ",dt) - # print("scaled desired error is ",desired_error) - - gam = stats.gamma.pdf(t, shape, location, scale) - - # A is a cascade with the appropriate decay rate - A = decay_rate * np.diag(np.ones((state_dim - 1)), -1) - decay_rate * np.diag( - np.ones((state_dim)), 0 - ) - # influence enters at the top state only - B = np.concatenate((np.ones((1, 1)), np.zeros((state_dim - 1, 1)))) - # contributions of states to the output will be scaled to match the gamma distribution - C = np.ones((1, state_dim)) * max(gam) - lti_sys = control.ss(A, B, C, 0) - - lti_approx = control.impulse_response(lti_sys, t) - """ - error = np.sum(np.abs(gam - lti_approx.y)) - if(verbose): - print("initial error") - print(error) - #print("desired error") - #print(max(gam)) - #print(desired_error) - """ - NSE = 1 - ( - np.sum(np.square(gam - lti_approx.y)) / np.sum(np.square(gam - np.mean(gam))) - ) - # if NSE is nan, set to -10e6 - if np.isnan(NSE): - NSE = -10e6 - - if verbose: - print("initial NSE") - print(NSE) - print("desired NSE") - print(desired_NSE) - - iterations = 0 - - speeds = [10, 5, 2, 1.1, 1.05, 1.01, 1.001] - speed_idx = 0 - leap = speeds[speed_idx] - # the area under the curve is normalized to be one. so rather than basing our desired error off the - # max of the distribution, it might be better to make it a percentage error, one percent or five percent - while NSE < desired_NSE and iterations < max_iterations: - - og_was_best = ( - True # start each iteration assuming that the original is the best - ) - # search across the C vector - for i in range( - C.shape[1] - 1, int(-1), int(-1) - ): # accross the columns # start at the end and come back - # for i in range(int(0),C.shape[1],int(1)): # accross the columns, start at the beginning and go forward - - og_approx = control.ss(A, B, C, 0) - og_y = np.ndarray.flatten(control.impulse_response(og_approx, t).y) - og_error = np.sum(np.abs(gam - og_y)) - og_NSE = 1 - (np.sum((gam - og_y) ** 2) / np.sum((gam - np.mean(gam)) ** 2)) - - Ctwice = np.array(C, copy=True) - Ctwice[0, i] = leap * C[0, i] - twice_approx = control.ss(A, B, Ctwice, 0) - twice_y = np.ndarray.flatten(control.impulse_response(twice_approx, t).y) - np.sum(np.abs(gam - twice_y)) - twice_NSE = 1 - ( - np.sum((gam - twice_y) ** 2) / np.sum((gam - np.mean(gam)) ** 2) - ) - - Chalf = np.array(C, copy=True) - Chalf[0, i] = (1 / leap) * C[0, i] - half_approx = control.ss(A, B, Chalf, 0) - half_y = np.ndarray.flatten(control.impulse_response(half_approx, t).y) - np.sum(np.abs(gam - half_y)) - half_NSE = 1 - ( - np.sum((gam - half_y) ** 2) / np.sum((gam - np.mean(gam)) ** 2) - ) - """ - Cneg = np.array(C,copy=True) - Cneg[0,i] = -C[0,i] - neg_approx = control.ss(A,B,Cneg,0) - neg_y = np.ndarray.flatten(control.impulse_response(neg_approx,t).y) - neg_error = np.sum(np.abs(gam - neg_y)) - neg_NSE = 1 - (np.sum((gam - neg_y)**2) / np.sum((gam - np.mean(gam))**2)) - """ - faster = np.array(A, copy=True) - faster[i, i] = A[i, i] * leap # faster decay - if abs(faster[i, i]) < abs(max_pole_speed): - if ( - i > 0 - ): # first reservoir doesn't receive contribution from another reservoir. want to keep B at 1 for scaling - faster[i, i - 1] = A[i, i - 1] * leap # faster rise - faster_approx = control.ss(faster, B, C, 0) - faster_y = np.ndarray.flatten( - control.impulse_response(faster_approx, t).y - ) - np.sum(np.abs(gam - faster_y)) - faster_NSE = 1 - ( - np.sum((gam - faster_y) ** 2) / np.sum((gam - np.mean(gam)) ** 2) - ) - else: - faster_NSE = -10e6 # disallowed because the pole is too fast - - slower = np.array(A, copy=True) - slower[i, i] = A[i, i] / leap # slower decay - if abs(slower[i, i]) > abs(min_pole_speed): - if i > 0: - slower[i, i - 1] = A[i, i - 1] / leap # slower rise - slower_approx = control.ss(slower, B, C, 0) - slower_y = np.ndarray.flatten( - control.impulse_response(slower_approx, t).y - ) - np.sum(np.abs(gam - slower_y)) - slower_NSE = 1 - ( - np.sum((gam - slower_y) ** 2) / np.sum((gam - np.mean(gam)) ** 2) - ) - else: - slower_NSE = -10e6 # disallowed because the pole is too slow - - # all_errors = [og_error, twice_error, half_error, faster_error, slower_error] - all_NSE = [ - og_NSE, - twice_NSE, - half_NSE, - faster_NSE, - slower_NSE, - ] # , neg_NSE] - - if twice_NSE >= max(all_NSE) and twice_NSE > og_NSE: - C = Ctwice - if twice_NSE > 1.001 * og_NSE: # an appreciable difference - og_was_best = False # did we change something this iteration? - elif half_NSE >= max(all_NSE) and half_NSE > og_NSE: - C = Chalf - if half_NSE > 1.001 * og_NSE: # an appreciable difference - og_was_best = False # did we change something this iteration? - - elif slower_NSE >= max(all_NSE) and slower_NSE > og_NSE: - A = slower - if slower_NSE > 1.001 * og_NSE: # an appreciable difference - og_was_best = False # did we change something this iteration? - elif faster_NSE >= max(all_NSE) and faster_NSE > og_NSE: - A = faster - if faster_NSE > 1.001 * og_NSE: # an appreciable difference - og_was_best = False # did we change something this iteration? - """ - elif (neg_NSE >= max(all_NSE) and neg_NSE > og_NSE): - C = Cneg - if neg_NSE > 1.001*og_NSE: - og_was_best = False - """ - - NSE = og_NSE - error = og_error - iterations += 1 # this shouldn't be the termination condition unless the resolution is too coarse - # normally the optimization should exit because the leap has become too small - if ( - og_was_best - ): # the original was the best, so we're going to tighten up the optimization - speed_idx += 1 - if speed_idx > len(speeds) - 1: - break # we're done - leap = speeds[speed_idx] - # print the iteration count every ten - # comment out for production - if iterations % 2 == 0 and verbose: - print("iterations = ", iterations) - print("error = ", error) - print("NSE = ", NSE) - print("leap = ", leap) - - lti_approx = control.ss(A, B, C, 0) - y = np.ndarray.flatten(control.impulse_response(og_approx, t).y) - error = np.sum(np.abs(gam - og_y)) - print("LTI_from_gamma final NSE") - print(NSE) - if verbose: - print("final system\n") - print("A") - print(A) - print("B") - print(B) - print("C") - print(C) - - print("\nfinal error") - print(error) - - # are any of the final eigenvalues outside the bounds specified? - E = np.linalg.eigvals(A) - if np.any(np.abs(E) > max_pole_speed) or np.any(np.abs(E) < min_pole_speed): - print("WARNING: final eigenvalues are outside the bounds specified") - - return { - "lti_approx": lti_approx, - "lti_approx_output": y, - "error": error, - "t": t, - "gamma_pdf": gam, - } - - -# this function takes the system data and the causative topology and returns an LTI system -# if the causative topology isn't already defined, it needs to be created using infer_causative_topology -def lti_system_gen( - causative_topology, - system_data, - independent_columns, - dependent_columns, - max_iter=250, - swmm=False, - bibo_stable=False, - max_transition_state_dim=50, - max_transforms=1, - early_stopping_threshold=0.005, -): - - # cast the columns and indices of causative_topology to strings so sindy can run properly - # We need the tuples to link the columns in system_data to the object names in the swmm model - # so we'll cast these back to tuples once we're done - if swmm: - causative_topology.columns = causative_topology.columns.astype(str) - causative_topology.index = causative_topology.index.astype(str) - - print("causative topology \n") - print(causative_topology.index) - print(causative_topology.columns) - - # do the same for dependent_columns and independent_columns - dependent_columns = [str(col) for col in dependent_columns] - independent_columns = [str(col) for col in independent_columns] - print(dependent_columns) - print(independent_columns) - - # do the same for the columns of system_data - system_data.columns = system_data.columns.astype(str) - print(system_data.columns) - - A = pd.DataFrame(index=dependent_columns, columns=dependent_columns) - B = pd.DataFrame(index=dependent_columns, columns=independent_columns) - C = pd.DataFrame(index=dependent_columns, columns=dependent_columns) - C.loc[:, :] = np.diag( - np.ones(len(dependent_columns)) - ) # these are the states which are observable - - # copy the corresponding entries from the causative topology into B - for row in B.index: - for col in B.columns: - B.loc[row, col] = causative_topology.loc[row, col] - # and into A - for row in A.index: - for col in A.columns: - A.loc[row, col] = causative_topology.loc[row, col] - - print("A") - print(A) - print("B") - print(B) - print("C") - print(C) - # use transform_only when calling delay_io_train to only train transfomrations for connections marked "d" - # train a MISO model for each output - delay_models: dict[Any, Any] = {key: None for key in dependent_columns} - - for row in A.index: - immediate_forcing = [] - delayed_forcing = [] - for col in A.columns: - if col == row: - continue # don't need to include the output state as a forcing variable. it's already included by default - if A[col][row] == "d": - delayed_forcing.append(col) - elif A[col][row] == "i": - immediate_forcing.append(col) - for col in B.columns: - if B[col][row] == "d": - delayed_forcing.append(col) - elif B[col][row] == "i": - immediate_forcing.append(col) - # make total_forcing the union of immediate and delayed forcing - total_forcing = immediate_forcing + delayed_forcing - feature_names = [row] + total_forcing - if delayed_forcing: - print( - "training delayed model for ", - row, - " with forcing ", - total_forcing, - "\n", - ) - delay_models[row] = delay_io_train( - system_data, - [row], - total_forcing, - transform_only=delayed_forcing, - max_transforms=max_transforms, - poly_order=1, - max_iter=max_iter, - verbose=False, - bibo_stable=bibo_stable, - ) - # we'll parse this delayed causation into the matrices A, B, and C later - else: - ####### TODO: incorporate bibo stability constraint into instantaneous fits ######## - print( - "training immediate model for ", - row, - " with forcing ", - total_forcing, - "\n", - ) - delay_models[row] = None - # we can put immediate causation into the matrices A, B, and C now - - if bibo_stable: # negative autocorrelatoin - # Figure out how many library features there will be - library = ps.PolynomialLibrary( - degree=1, include_bias=False, include_interaction=False - ) - # total_train = pd.concat((response,forcing), axis='columns') - # fit on a dummy (2, n_features) array; 2 rows is the minimum pysindy requires - library.fit(np.zeros((2, len(feature_names)))) - n_features = library.n_output_features_ - # print(f"Features ({n_features}):", library.get_feature_names()) - # Set constraints - # n_targets = total_train.shape[1] # not sure what targets means after reading through the pysindy docs - # print("n_targets") - # print(n_targets) - constraint_rhs = np.zeros(1) - # one row per constraint, one column per coefficient - constraint_lhs = np.zeros((1, n_features)) - - # print(constraint_rhs) - # print(constraint_lhs) - # constrain the highest order output autocorrelation to be negative - # this indexing is only right for include_interaction=False, include_bias=False, and pure polynomial library - # for more complex libraries, some conditional logic will be needed to grab the right column - constraint_lhs[:, 0] = 1 - - model = ps.SINDy( - differentiation_method=ps.FiniteDifference(), - feature_library=ps.PolynomialLibrary( - degree=1, include_bias=False, include_interaction=False - ), - optimizer=_ConstrainedSR3( - reg_weight_lam=0, - regularizer="l2", - constraint_lhs=constraint_lhs, - constraint_rhs=constraint_rhs, - inequality_constraints=True, - ), - ) - - else: # unoconstrained - model = ps.SINDy( - differentiation_method=ps.FiniteDifference( - order=10, drop_endpoints=True - ), - feature_library=ps.PolynomialLibrary( - degree=1, include_bias=False, include_interaction=False - ), - optimizer=ps.optimizers.STLSQ(threshold=0, alpha=0), - ) - if system_data.loc[ - :, immediate_forcing - ].empty: # the subsystem is autonomous - instant_fit = model.fit( - x=system_data.loc[:, row], t=np.arange(0, len(system_data.index), 1) - ) - instant_fit.print(precision=3) - print( - "Training r2 = ", - instant_fit.score( - x=system_data.loc[:, row], - t=np.arange(0, len(system_data.index), 1), - ), - ) - print(instant_fit.coefficients()) - else: # there is some forcing - # instant_fit = model.fit(x = system_data.loc[:,row] ,t = system_data.index.values, u = system_data.loc[:,immediate_forcing]) # sindy can't take datetime indices - instant_fit = model.fit( - x=system_data.loc[:, row], - t=np.arange(0, len(system_data.index), 1), - u=system_data.loc[:, immediate_forcing], - ) - instant_fit.print(precision=3) - print( - "Training r2 = ", - instant_fit.score( - x=system_data.loc[:, row], - t=np.arange(0, len(system_data.index), 1), - u=system_data.loc[:, immediate_forcing], - ), - ) - print(instant_fit.coefficients()) - for idx in range(len(feature_names)): - if feature_names[idx] in A.columns: - A.loc[row, feature_names[idx]] = instant_fit.coefficients()[0][idx] - elif feature_names[idx] in B.columns: - B.loc[row, feature_names[idx]] = instant_fit.coefficients()[0][idx] - else: - print("couldn't find a column for ", feature_names[idx]) - # print("updated A") - # print(A) - # print("updated B") - # print(B) - - original_A = A.copy(deep=True) - # now, parse the delay models into the A, B, and C matrices - # the changes will be as follows: - # the A matrix will have matrices of the form [B_gam, A_gam; 0 , C_gam] inserted into it - # where X_gam are the matrices generated by the lti_from_gamma function to represent the delayed causation shape - # the B and C matrices will just have zeros inserted into them to maintain compatible dimensions - # none of these cascades are observable or directly receive input. - for row in original_A.index: - if delay_models[row] is None: - pass - else: # we want the model with the most transformations where the last trnasformation added at least 0.5% to the R2 score - for num_transforms in range(1, max_transforms + 1): - if num_transforms == 1: - optimal_number_transforms = num_transforms - elif ( - delay_models[row][num_transforms]["final_model"]["error_metrics"][ - "r2" - ] - - delay_models[row][num_transforms - 1]["final_model"][ - "error_metrics" - ]["r2"] - < early_stopping_threshold - ): - optimal_number_transforms = num_transforms - 1 - break # improvement is too small to justify additional complexity - else: - optimal_number_transforms = ( - num_transforms # the most recent one was worth it - ) - - transformation_approximations: dict[Any, Any] = { - transform_key: None - for transform_key in delay_models[row][optimal_number_transforms][ - "shape_factors" - ].columns - } - for transform_key in transformation_approximations.keys(): # which input - for idx in range( - 1, optimal_number_transforms + 1 - ): # which transformation - print("variable = ", transform_key, ", transformation = ", idx) - delay_models[row][optimal_number_transforms]["final_model"][ - "model" - ].print(precision=5) - shape = delay_models[row][optimal_number_transforms][ - "shape_factors" - ].loc[idx, transform_key] - scale = delay_models[row][optimal_number_transforms][ - "scale_factors" - ].loc[idx, transform_key] - loc = delay_models[row][optimal_number_transforms][ - "loc_factors" - ].loc[idx, transform_key] - """ - # infer the timestep of system_data from the index - timestep = system_data.index[1] - system_data.index[0] - try: # if the timestep is numeric - pd.to_numeric(timestep) - transformation_approximations[transform_key] = lti_from_gamma(shape,scale,loc,dt=timestep) - - Agam = transformation_approximations[transform_key]['lti_approx'].A / timestep - Bgam = transformation_approximations[transform_key]['lti_approx'].B / timestep - Cgam = transformation_approximations[transform_key]['lti_approx'].C / timestep - except Exception as e: # if the timestep is something like a datetime - print(e)""" - # this will get overwritten if we use more than one transformation per input. i think that's okay. - transformation_approximations[transform_key] = lti_from_gamma( - shape, scale, loc, max_state_dim=max_transition_state_dim - ) - - Agam = transformation_approximations[transform_key]["lti_approx"].A - Bgam = transformation_approximations[transform_key][ - "lti_approx" - ].B # only entry is unit impulse at top state - Cgam = transformation_approximations[transform_key]["lti_approx"].C - - tr_string = str("_tr_" + str(idx)) - - # Cgam needs to be scaled by the coefficient the forcing term had in the delay model - # coefficients = {coef_key: None for coef_key in delay_models[row][1]['final_model']['model'].feature_names} - coefficients = { - coef_key: None - for coef_key in delay_models[row][optimal_number_transforms][ - "final_model" - ]["model"].feature_names - } - for coef_key in coefficients.keys(): - coef_index = delay_models[row][optimal_number_transforms][ - "final_model" - ]["model"].feature_names.index(coef_key) - coefficients[coef_key] = delay_models[row][ - optimal_number_transforms - ]["final_model"]["model"].coefficients()[0][coef_index] - # if "_tr_1" in coef_key and coef_key.replace("_tr_1","") == transform_key.replace("_tr_1",""): - if tr_string in coef_key and coef_key.replace( - tr_string, "" - ) == transform_key.replace(tr_string, ""): - """ - try: - pd.to_numeric(timestep,errors='raise') - Cgam = Cgam * coefficients[coef_key] / timestep - except Exception as e: - print(e) - Cgam = Cgam * coefficients[coef_key] - """ - - Cgam = Cgam * coefficients[coef_key] # scaling - else: # these are the immediate effects, insert them now - if coef_key in A.columns: - A.loc[row, coef_key] = coefficients[coef_key] - elif coef_key in B.columns: - B.loc[row, coef_key] = coefficients[coef_key] - - Agam_index = [] - for agam_idx in range(Agam.shape[0]): - # Agam_index.append(transform_key.replace("_tr_1","") + "->" + row + "_" + str(idx)) - Agam_index.append( - transform_key.replace(tr_string, "") - + "->" - + row - + tr_string - + "_" - + str(agam_idx) - ) - Agam = pd.DataFrame(Agam, index=Agam_index, columns=Agam_index) - Bgam = pd.DataFrame( - Bgam, - index=Agam_index, - columns=[transform_key.replace(tr_string, "")], - ) - Cgam = pd.DataFrame(Cgam, index=[row], columns=Agam_index) - # print("Agam") - # print(Agam) - # print("Bgam") - # print(Bgam) - # print("Cgam") - # print(Cgam) - # insert these into the A, B, and C matrices - # for Agam, the insertion row is immediately after the source (key) - # the insertion column is also immediately after the source (key) - - ### everything below this point is garbage. not performing at all as desired at the moment - - # first need to create space for the new rows and columns - # create before_index and after_index variables, which record the parts of the index of A that occur before and after row - before_index = [] - # after_index = [] - # if transform_key.replace("_tr_1","") not in A.index: # it's one of the forcing terms. put it in at the beginning - if ( - transform_key.replace(tr_string, "") not in A.index - ): # it's one of the forcing terms. put it in at the beginning - after_index = list( - A.index - ) # it's a forcing variable, so we don't want it in the newA index - else: # it is a state variable - # before_index = list(A.index[:A.index.get_loc(transform_key.replace("_tr_1",""))]) - before_index = list( - A.index[ - : A.index.get_loc(transform_key.replace(tr_string, "")) - ] - ) - - # after_index = list(A.index[A.index.get_loc(transform_key.replace("_tr_1",""))+1:]) - after_index = list( - A.index[ - A.index.get_loc(transform_key.replace(tr_string, "")) - + 1 : - ] - ) - - """ - for idx in A.index: - if idx == key.replace("_tr_1",""): - before_index.append(idx) # if it's a state variable, we want it in the newA index - break - else: - before_index.append(idx) - for idx in range(A.index.get_loc(key.replace("_tr_1",""))+1,len(A.index)): - after_index.append(A.index[idx]) - """ - # if transform_key.replace("_tr_1","") in A.index: # the transform key refers to a state (x) - if transform_key.replace(tr_string, "") in A.index: - # states = before_index + [transform_key.replace("_tr_1","")] + Agam_index + after_index # state dim expands by the number of rows in Agam - states = ( - before_index - + [transform_key.replace(tr_string, "")] - + Agam_index - + after_index - ) # state dim expands by the number of rows in Agam - # include the current transform key in A because it's a state variable - # elif transform_key.replace("_tr_1","") in B.columns: # the transform key refers to a control input (u) - elif ( - transform_key.replace(tr_string, "") in B.columns - ): # the transform key refers to a control input (u) - states = ( - before_index + Agam_index + after_index - ) # state dim expands by the number of rows in Agam - # don't include the current transform key in A because it's a control input, not a state variable - - newA = pd.DataFrame(index=states, columns=states) - newB = pd.DataFrame( - index=states, columns=B.columns - ) # input dim remains consistent (columns of B) - newC = pd.DataFrame( - index=C.index, columns=states - ) # output dim remains consistent (rows of C) - - # fill in newA with the corresponding entries from A - for idx in newA.index: - for col in newA.columns: - if ( - idx in A.index and col in A.columns - ): # if it's in the original A matrix, copy it over - newA.loc[idx, col] = A.loc[idx, col] - if ( - idx in Agam.index and col in Agam.columns - ): # if it's in Agam, copy it over - newA.loc[idx, col] = Agam.loc[idx, col] - if ( - idx in Bgam.index and col in Bgam.columns - ): # the input to the cascade is a state - newA.loc[idx, col] = Bgam.loc[idx, col] - - for idx in newB.index: - for col in newB.columns: - if ( - idx in B.index and col in B.columns - ): # if it's in the original B matrix, copy it over - newB.loc[idx, col] = B.loc[idx, col] - if ( - idx in Bgam.index and col in Bgam.columns - ): # the input to the cascade is a forcing term - newB.loc[idx, col] = Bgam.loc[idx, col] - - for idx in newC.index: - for col in newC.columns: - if ( - idx in C.index and col in C.columns - ): # if it's in the original C matrix, copy it over - newC.loc[idx, col] = C.loc[idx, col] - if ( - idx in Cgam.index and col in Cgam.columns - ): # outputs from the cascades - newA.loc[idx, col] = Cgam.loc[idx, col] - - # print("newA") - # print(newA.to_string()) - # print("newB") - # print(newB.to_string()) - # print("newC") - # print(newC.to_string()) - - # copy over - A = newA.copy(deep=True) - B = newB.copy(deep=True) - C = newC.copy(deep=True) - - A.replace("n", 0.0, inplace=True) - B.replace("n", 0.0, inplace=True) - C.replace("n", 0.0, inplace=True) - - if swmm: - pass - ############# - # TODO: cast strings back to tuples in the indices and columns - ############# - # cast the index and columns of causative_topology to tuples. they'll be of the form "(X,Y)" - - # do the same for dependent_columns and independent_columns - - # do the same for the columns of system_data - - A = A.apply(pd.to_numeric, errors="coerce").fillna(0.0) - B = B.apply(pd.to_numeric, errors="coerce").fillna(0.0) - C = C.apply(pd.to_numeric, errors="coerce").fillna(0.0) - - # if bibo_stable is specified and A not hurwitz, make A hurwitz by defining A' = A - I*max(real(eig(A))) - # this will gaurantee stability (max eigenvalue will have real part < 0) - if bibo_stable: - orig_eigs, _ = np.linalg.eig(A) - if any(np.real(orig_eigs) > 0): - print("stabilizing unstable plant by subtracting I*max(real(eig)) from A") - epsilon = 10e-4 - A_stab = A - np.eye(len(A)) * (1 + epsilon) * max( - np.real(orig_eigs) - ) # add factor of (1+epsilon) for stability, not marginal stabilty - stab_eigs, _ = np.linalg.eig(A_stab) - A = A_stab.copy(deep=True) - - # sindy will scale the coefficients according to the timestep if the index is numeric - # so the whole system needs to be scaled by the timestep if its numeric - try: - pd.to_numeric( - system_data.index, errors="raise" - ) # can the index be converted to a numeric type? - dt = system_data.index.values[1] - system_data.index.values[0] - A = A / dt - B = B / dt - C = C # what we observe doesn't need to be adjusted, just the dynamics - print("system response data index converted to numeric type. dt = ") - print(dt) - except Exception as e: - print(e) - dt = None - - # cast all of A, B, and C to type float (integers cause issues with LQR / LQE calculations) - A = A.astype(float) - B = B.astype(float) - C = C.astype(float) - - lti_sys = control.ss( - A, B, C, 0, inputs=B.columns, outputs=C.index, states=A.columns - ) - - # returning the matrices too because control.ss strips the labels from the pandas dataframes and stores them as numpy matrices - return {"system": lti_sys, "A": A, "B": B, "C": C} - - -def find_topology_no_geo( - system_data, - dependent_columns, - independent_columns, - max_iterations=250, - graph_type="Weak-Conn", - verbose=False, - sensor_locations=None, - init_neighbors=3, -): - """ - Infer network topology from time series data using SINDy-based optimization. - - Args: - system_data: pd.DataFrame with time series data, columns are variables - dependent_columns: list of column names that are dependent variables - independent_columns: list of column names that are independent/forcing variables - max_iterations: maximum iterations for optimization - graph_type: type of graph connectivity requirement ('Weak-Conn') - verbose: whether to print detailed output - sensor_locations: optional dict mapping column names to {"lat": float, "lon": float}. - If provided, uses geographic filtering to reduce computation by only evaluating - nearby sensors as potential forcings. Format: {"station_A": {"lat": 41.5, "lon": -74.5}, ...} - init_neighbors: initial number of nearest neighbors to evaluate when sensor_locations - is provided (default: 3). Ignored if sensor_locations is None. - - Returns: - dict with keys: "edges", "best_params", "r2_values", "lead_lag" - """ - - # only print 3 places past the decimal for floats. don't use scientific notation. if less than 0.001, print as <0.001 - pd.options.display.float_format = "{:.3f}".format - - # Helper function to find the lag with strongest cross-correlation - def cross_correlation_lag(x, y, max_lag): - """Find the lag with strongest cross-correlation between x and y. - - Returns: - best_lag: Positive lag means x leads y (x happens before y) - Negative lag means y leads x (y happens before x) - best_corr: The correlation coefficient at best_lag - """ - best_lag, best_corr = 0, -2 - for lag in range(-max_lag, max_lag + 1): - if lag < 0: - xs = x.iloc[-lag:] - ys = y.iloc[: len(xs)] - elif lag > 0: - ys = y.iloc[lag:] - xs = x.iloc[: len(ys)] - else: - xs, ys = x, y - if len(xs) < 5 or xs.std() == 0 or ys.std() == 0: - continue - c = np.corrcoef(xs, ys)[0, 1] - if np.isnan(c): - continue - if c > best_corr: - best_corr, best_lag = c, lag - return best_lag, best_corr - - # drop columns from system_data which aren't in dependent_columns or independent_columns - # this ensures we only analyze the variables of interest - system_data = pd.concat( - (system_data[independent_columns], system_data[dependent_columns]), - axis="columns", - ) - - # Store results for each column pair - best_params = pd.DataFrame( - index=dependent_columns, columns=system_data.columns, dtype=object - ) - r2_values = pd.DataFrame( - index=dependent_columns, columns=system_data.columns, dtype=float - ) - lead_lag = pd.DataFrame( - index=dependent_columns, columns=system_data.columns, dtype=float - ) - edges = pd.DataFrame( - index=system_data.columns, columns=system_data.columns, dtype=int, data=0 - ) # from column, to row. causation, not flow. - - for dep_col in dependent_columns: - _ = np.array(system_data[dep_col].values) - - # First, compute autocorrelation-only R² (no external forcing) - # This tells us how much of the dynamics can be explained by the state alone - # print(f"\nComputing autocorrelation R² for {dep_col}") - model = ps.SINDy( - differentiation_method=ps.FiniteDifference(order=10, drop_endpoints=True), - feature_library=ps.PolynomialLibrary( - degree=2, include_bias=False, include_interaction=False - ), - optimizer=ps.optimizers.STLSQ(threshold=0, alpha=0), - ) - # Fit with no control input (u=None), just the state - fit = model.fit( - x=system_data.loc[:, dep_col], - t=np.arange(0, len(system_data.index), 1), - feature_names=[dep_col], - ) - auto_r2 = fit.score( - x=system_data.loc[:, dep_col], t=np.arange(0, len(system_data.index), 1) - ) - r2_values.loc[dep_col, dep_col] = auto_r2 - # print(f" Autocorrelation R² for {dep_col}: {auto_r2:.4f}") - # try: - # model.print() - # except Exception as e: - # print(e) - - for forcing_col in system_data.columns: - if forcing_col == dep_col: - continue # already computed autocorrelation above - - # EXPERIMENTAL: Check lead/lag before expensive SISO optimization - # Skip if forcing doesn't lead response (comment out to disable this check) - max_lag_check = min(len(system_data) // 4, 100) - early_lag, early_xcorr = cross_correlation_lag( - system_data[forcing_col], system_data[dep_col], max_lag_check - ) - if early_lag < -5: - print( - f"\nSkipping {forcing_col} -> {dep_col}: forcing lags response (lag={early_lag})" - ) - lead_lag.loc[dep_col, forcing_col] = early_lag - r2_values.loc[dep_col, forcing_col] = 0.0 - best_params.loc[dep_col, forcing_col] = ( - 2.0, - 2.0, - 0.0, - ) # default params - continue - # END EXPERIMENTAL - - print(f"\nOptimizing transformation for {forcing_col} -> {dep_col}") - forcing_orig = system_data[[forcing_col]].copy(deep=True) - - # Objective function to minimize (negative because we want to maximize correlation - p_value) - def objective(params): - shape, scale, loc = params - - # Create transformation parameter DataFrames - shape_factors = pd.DataFrame(columns=[forcing_col], index=[1]) - shape_factors.loc[1, forcing_col] = shape - scale_factors = pd.DataFrame(columns=[forcing_col], index=[1]) - scale_factors.loc[1, forcing_col] = scale - loc_factors = pd.DataFrame(columns=[forcing_col], index=[1]) - loc_factors.loc[1, forcing_col] = loc - - try: - # build the candidate input set - # selected_inputs = list(edges.loc[output_variable, edges.loc[output_variable,:] == 1].index) - # candidate_inputs = selected_inputs + [forcing_variable] - # build the transformed timeseries for these candidate inputs using the best transformation parameters found earlier - transformed_inputs = pd.DataFrame(index=system_data.index) - """ - for input_var in candidate_inputs: - shape, scale, loc = best_params.loc[output_variable, input_var] - shape_factors = pd.DataFrame(columns=[input_var], index=[1]) - shape_factors.loc[1, input_var] = shape - scale_factors = pd.DataFrame(columns=[input_var], index=[1]) - scale_factors.loc[1, input_var] = scale - loc_factors = pd.DataFrame(columns=[input_var], index=[1]) - loc_factors.loc[1, input_var] = loc - forcing_orig = system_data[[input_var]].copy() - transformed = transform_inputs(shape_factors, scale_factors, loc_factors, - system_data.index, forcing_orig) - transformed_inputs = pd.concat((transformed_inputs, transformed[[input_var + "_tr_1"]]), axis='columns') - """ - # SINDY way - transformed = transform_inputs( - shape_factors, - scale_factors, - loc_factors, - system_data.index, - forcing_orig, - ) - transformed_inputs = pd.concat( - (transformed_inputs, transformed[[forcing_col + "_tr_1"]]), - axis="columns", - ) - # build a sindy model with these inputs - # feature_names = [output_variable] + candidate_inputs - feature_names = [dep_col, str(forcing_col + "_tr_1")] - model = ps.SINDy( - differentiation_method=ps.FiniteDifference( - order=10, drop_endpoints=True - ), - feature_library=ps.PolynomialLibrary( - degree=2, include_bias=False, include_interaction=False - ), - optimizer=ps.optimizers.STLSQ(threshold=0, alpha=0), - ) - # fit = model.fit(x = system_data.loc[:,output_variable] ,t = np.arange(0,len(system_data.index),1) , u = transformed_inputs, - # feature_names = feature_names) - fit = model.fit( - x=system_data.loc[:, dep_col], - u=transformed_inputs, - t=np.arange(0, len(system_data.index), 1), - feature_names=feature_names, - ) - r2 = fit.score( - x=system_data.loc[:, dep_col], - u=transformed_inputs, - t=np.arange(0, len(system_data.index), 1), - ) - # model.print(precision=5) - - """ - # polynomial regression way (might be faster than sindy, doesn't consider autocorrelation) - _ = np.array(transformed[forcing_col + "_tr_1"].values) - - - # fourth order polynomial regression between transformed forcing and derivative of response - coeffs = np.polyfit(forcing, np.gradient(response), 4) - r_value_poly = np.corrcoef(np.polyval(coeffs, forcing), np.gradient(response))[0, 1] - - # r^2 likely makes more sense as our criterion. - r2 = sklearn.metrics.r2_score(np.gradient(response), np.polyval(coeffs, forcing)) - """ - - return -r2 # Negative because minimize - except Exception as e: - # if e contains any letters or numbers, print it for debugging - if any(c.isalnum() for c in str(e)): - if verbose: - print(f"Exception in objective function: {e}") - - return 1e10 # Large penalty for invalid parameters - - # Initial guess and bounds - x0 = [2.0, 2.0, 0.0] - bounds = [(1.0, 300.0), (1e-5, 300.0), (0, 300.0)] # shape, scale, loc - - # Optimize - # result = minimize(objective, x0, method='Nelder-Mead', - # options={'maxiter': 5, 'disp': verbose}) - # optimize using a method that supports bounds - result = minimize( - objective, - x0, - method="Nelder-Mead", - bounds=bounds, - options={"maxiter": max_iterations, "disp": verbose, "fatol": 1e-4}, - ) - # can use 'fatol' keyword argument to set convergence tolerance if speedup is desired. - # I'm worried about losing accuracy with that though. - - # result = minimize(objective, x0, method='trust-constr', bounds=bounds, - # options={'maxiter': max_iterations, 'disp': verbose}) - # L-BFGS-B did not get nearly as good of results as Nelder-Mead in testing. maybe there are local minima in the objective. - # trust-constr was also worse than nelder-mead. - # Store best results - best_shape, best_scale, best_loc = result.x - - # Compute final correlation and p_value with best parameters - shape_factors = pd.DataFrame(columns=[forcing_col], index=[1]) - shape_factors.loc[1, forcing_col] = best_shape - scale_factors = pd.DataFrame(columns=[forcing_col], index=[1]) - scale_factors.loc[1, forcing_col] = best_scale - loc_factors = pd.DataFrame(columns=[forcing_col], index=[1]) - loc_factors.loc[1, forcing_col] = best_loc - - transformed = transform_inputs( - shape_factors, - scale_factors, - loc_factors, - system_data.index, - forcing_orig, - ) - _ = np.array(transformed[forcing_col + "_tr_1"].values) - feature_names = [dep_col, forcing_col] - model = ps.SINDy( - differentiation_method=ps.FiniteDifference( - order=10, drop_endpoints=True - ), - feature_library=ps.PolynomialLibrary( - degree=2, include_bias=False, include_interaction=False - ), - optimizer=ps.optimizers.STLSQ(threshold=0, alpha=0), - ) - fit = model.fit( - x=system_data.loc[:, dep_col], - t=np.arange(0, len(system_data.index), 1), - u=transformed, - feature_names=feature_names, - ) - # evaluate the r2 score - r2 = fit.score( - x=system_data.loc[:, dep_col], - t=np.arange(0, len(system_data.index), 1), - u=transformed, - ) - try: - model.print() - except Exception as e: - print(e) - - r2_values.loc[dep_col, forcing_col] = r2 - - # Compute cross-correlation lag between forcing and response - # Use max_lag of 1/4 of the data length, capped at 100 - max_lag = min(len(system_data) // 4, 100) - best_lag, best_xcorr = cross_correlation_lag( - system_data[forcing_col], system_data[dep_col], max_lag - ) - lead_lag.loc[dep_col, forcing_col] = best_lag - - print(f"\nOptimizing transformation for {forcing_col} -> {dep_col}") - print( - f" BEST: shape={best_shape:.2f}, scale={best_scale:.2f}, loc={best_loc:.2f}" - ) - print(f" Cross-correlation: lag={best_lag}, corr={best_xcorr:.4f}") - # save the best parameters - best_params.loc[dep_col, forcing_col] = (best_shape, best_scale, best_loc) - - print("R2 Values:") - print(r2_values) - print("\n") - - print("Final SISO R2 Values:") - print(r2_values) - current_best_r2 = pd.Series(index=dependent_columns, dtype=float, data=0.0) - print("Lead/Lag Matrix: (positive lag means forcing leads response)") - print(lead_lag) - - # OPTION A: Mask r2 values by nonnegative lead/lag (forcing must lead response) - # This is applied AFTER SISO optimization - use this if not skipping early - # r2_values = r2_values.mask(lead_lag < 0, 0) - # print("Masked R2 Values (only forcing leads response):") - # print(r2_values) - - # OPTION B: Early skip is done above in the SISO loop - r2_values already has 0s for skipped pairs - - # first identify the maximum r^2 value in each row. we know these will be included in the final topology - # with an exception: if we form a cycle with these initial edges, remove the lowest r^2 edge in the cycle - # for dep_col in dependent_columns: - # forcing_col = r2_values.loc[dep_col,:].idxmax() - # edges.loc[dep_col,forcing_col] = 1 - # current_best_r2[dep_col] = r2_values.loc[dep_col,forcing_col] - - # try a different method of picking initial edges - # find the n_columns edges in r2_values with the highest r^2 values - # if they are the maximum in their row and column, include them - sorted_r2 = r2_values.stack().sort_values(ascending=False) - for idx in sorted_r2.index: - dep_col = idx[0] - forcing_col = idx[1] - r2 = r2_values.loc[dep_col, forcing_col] - # is this the maximum in its row and column? (strongest connection for giver and receiver) - if ( - r2 == r2_values.loc[dep_col, :].max() - and r2 == r2_values.loc[:, forcing_col].max() - ): - edges.loc[dep_col, forcing_col] = 1 - current_best_r2[dep_col] = r2_values.loc[dep_col, forcing_col] - print(f"Initial edge added: {forcing_col} -> {dep_col} with r^2 = {r2:.4f}") - - # check for cycles and remove them iteratively - G = nx.from_pandas_adjacency(edges, create_using=nx.DiGraph) - while True: - try: - # find_cycle returns a list of edges forming ONE cycle: [(u, v, dir), (v, w, dir), ...] - cycle_edges = list(nx.find_cycle(G, orientation="original")) - if len(cycle_edges) == 0: - break - - print( - f"Found cycle with {len(cycle_edges)} edges. Removing lowest r^2 edge." - ) - print(f"Cycle edges: {[(e[0], e[1]) for e in cycle_edges]}") - - # find the edge with the lowest r^2 in the cycle - min_r2 = float("inf") - edge_to_remove = None - for edge in cycle_edges: - from_node = edge[0] # source node - to_node = edge[1] # target node - # In our adjacency matrix, edges.loc[row, col] = 1 means col -> row - # So we need r2_values.loc[to_node, from_node] for edge from_node -> to_node - r2 = r2_values.loc[to_node, from_node] - print(f" Edge {from_node} -> {to_node}: r^2 = {r2:.4f}") - if r2 < min_r2: - min_r2 = r2 - edge_to_remove = (from_node, to_node) - - # remove this edge from our edges DataFrame - # edges.loc[row, col] = 1 means col -> row, so to remove from_node -> to_node: - edges.loc[edge_to_remove[1], edge_to_remove[0]] = 0 - print( - f" Removed edge {edge_to_remove[0]} -> {edge_to_remove[1]} with r^2 = {min_r2:.4f}" - ) - - # rebuild the graph for next iteration - G = nx.from_pandas_adjacency(edges, create_using=nx.DiGraph) - - except nx.NetworkXNoCycle: - # No cycle found, we're done - print("No cycles detected in initial edges.") - break - except Exception as e: - print(f"Error during cycle detection: {e}") - break - - # Helper function to update correlation-weighted R² scores for a single output variable - def update_corr_weighted_r2(dep_col): - """Update corr_wted_r2 for all potential inputs to dep_col based on current edges.""" - selected_inputs = list(edges.loc[dep_col, edges.loc[dep_col, :] == 1].index) - for forcing_col in system_data.columns: - if forcing_col in selected_inputs or forcing_col == dep_col: - continue # skip already selected inputs / autocorrelation - - if len(selected_inputs) > 0: - correlations = [] - for sel_input in selected_inputs: - # compute correlation between transformed versions of forcing_col and sel_input - shape_factors_1 = pd.DataFrame(columns=[forcing_col], index=[1]) - shape_factors_1.loc[1, forcing_col] = best_params.loc[ - dep_col, forcing_col - ][0] - scale_factors_1 = pd.DataFrame(columns=[forcing_col], index=[1]) - scale_factors_1.loc[1, forcing_col] = best_params.loc[ - dep_col, forcing_col - ][1] - loc_factors_1 = pd.DataFrame(columns=[forcing_col], index=[1]) - loc_factors_1.loc[1, forcing_col] = best_params.loc[ - dep_col, forcing_col - ][2] - transformed_1 = transform_inputs( - shape_factors_1, - scale_factors_1, - loc_factors_1, - system_data.index, - system_data[[forcing_col]], - ) - - shape_factors_2 = pd.DataFrame(columns=[sel_input], index=[1]) - shape_factors_2.loc[1, sel_input] = best_params.loc[ - dep_col, sel_input - ][0] - scale_factors_2 = pd.DataFrame(columns=[sel_input], index=[1]) - scale_factors_2.loc[1, sel_input] = best_params.loc[ - dep_col, sel_input - ][1] - loc_factors_2 = pd.DataFrame(columns=[sel_input], index=[1]) - loc_factors_2.loc[1, sel_input] = best_params.loc[ - dep_col, sel_input - ][2] - transformed_2 = transform_inputs( - shape_factors_2, - scale_factors_2, - loc_factors_2, - system_data.index, - system_data[[sel_input]], - ) - - together = pd.DataFrame(index=system_data.index) - together[forcing_col] = transformed_1[str(forcing_col + "_tr_1")] - together[sel_input] = transformed_2[str(sel_input + "_tr_1")] - - # Check for zero variance before computing correlation - if ( - together[forcing_col].std() == 0 - or together[sel_input].std() == 0 - ): - corr = 2.0 # constant variable, exclude it - else: - corr = np.corrcoef(together[forcing_col], together[sel_input])[ - 0, 1 - ] - if np.isnan(corr): - corr = 0.0 - correlations.append(abs(corr)) - _ = np.max(correlations) - else: - _ = 0.0 - - corr_wted_r2.loc[dep_col, forcing_col] = ( - r2_values.loc[dep_col, forcing_col] * 1 - ) # ((1 - max_corr)) # was **10 - - # Initialize correlation-weighted R² scores - corr_wted_r2 = r2_values.copy(deep=True) - for dep_col in dependent_columns: - update_corr_weighted_r2(dep_col) - - sorted_r2 = r2_values.stack().sort_values(ascending=False) - if verbose: - print("Sorted R2 values:") - print(sorted_r2) - - # Use a while loop so we can re-sort after each edge addition - # This ensures we always pick the best remaining candidate after correlation weights are updated - evaluated_pairs = ( - set() - ) # Track pairs we've already evaluated to avoid infinite loops - - while True: - sorted_corr_wted_r2 = corr_wted_r2.stack().sort_values(ascending=False) - # Find the best candidate we haven't evaluated yet - idx = None - for candidate_idx in sorted_corr_wted_r2.index: - if ( - candidate_idx not in evaluated_pairs - and edges.loc[candidate_idx[0], candidate_idx[1]] != 1 - ): - idx = candidate_idx - break - - if idx is None: - print("No more candidate edges to evaluate.") - break - - evaluated_pairs.add(idx) - output_variable = idx[0] - forcing_variable = idx[1] - r2 = r2_values.loc[output_variable, forcing_variable] - - non_rain_edges = edges.loc[ - ~edges.index.str.contains("rain"), ~edges.columns.str.contains("rain") - ] - - # would adding this edge reduce the number of components in the graph? (not considering rain) - non_rain_edges_if_added = non_rain_edges.copy(deep=True) - non_rain_edges_if_added.loc[output_variable, forcing_variable] = 1 - - n_components_now = nx.number_weakly_connected_components( - nx.from_pandas_adjacency(non_rain_edges, create_using=nx.DiGraph) - ) - if n_components_now == 1: - print("graph is weakly connected.") - # done - break - - n_components = nx.number_weakly_connected_components( - nx.from_pandas_adjacency(non_rain_edges_if_added, create_using=nx.DiGraph) - ) - if "rain" not in forcing_variable.lower(): # always allow rain edges - if n_components >= n_components_now: - print( - f"Skipping addition of {forcing_variable} -> {output_variable} as it does not improve connectivity" - ) - continue # skip this addition as it doesn't improve connectivity - - print( - f"Evaluating edge {forcing_variable} -> {output_variable} with r2 = {r2:.4f}" - ) - print("current best r2 values:") - print(current_best_r2) - # build the candidate input set - selected_inputs = list( - edges.loc[output_variable, edges.loc[output_variable, :] == 1].index - ) - candidate_inputs = selected_inputs + [forcing_variable] - - # optimize the transformations for all candidate inputs together, using siso best params as initial guesses - def joint_objective(params, debug=False): - # params is a flat list of shape, scale, loc for each candidate input - transformed_inputs = pd.DataFrame(index=system_data.index) - for i, input_var in enumerate(candidate_inputs): - shape = params[i * 3] - scale = params[i * 3 + 1] - loc = params[i * 3 + 2] - shape_factors = pd.DataFrame(columns=[input_var], index=[1]) - shape_factors.loc[1, input_var] = shape - scale_factors = pd.DataFrame(columns=[input_var], index=[1]) - scale_factors.loc[1, input_var] = scale - loc_factors = pd.DataFrame(columns=[input_var], index=[1]) - loc_factors.loc[1, input_var] = loc - forcing_orig = system_data[[input_var]].copy() - transformed = transform_inputs( - shape_factors, - scale_factors, - loc_factors, - system_data.index, - forcing_orig, - ) - # Include BOTH original and transformed columns, consistent with SISO phase - transformed_inputs = pd.concat( - (transformed_inputs, transformed), axis="columns" - ) - # build and fit the sindy model - feature_names = [output_variable] + list(transformed_inputs.columns) - model = ps.SINDy( - differentiation_method=ps.FiniteDifference( - order=10, drop_endpoints=True - ), - feature_library=ps.PolynomialLibrary( - degree=2, include_bias=False, include_interaction=False - ), - optimizer=ps.optimizers.STLSQ(threshold=0, alpha=0), - ) - fit = model.fit( - x=system_data.loc[:, output_variable], - t=np.arange(0, len(system_data.index), 1), - u=transformed_inputs, - feature_names=feature_names, - ) - r2 = fit.score( - x=system_data.loc[:, output_variable], - t=np.arange(0, len(system_data.index), 1), - u=transformed_inputs, - ) - if debug: - print( - f" DEBUG joint_objective: inputs={list(transformed_inputs.columns)}, r2={r2:.4f}" - ) - try: - model.print() - except Exception: - pass - return -r2 # Negative because minimize - - # initial guesses from SISO optimization - x0 = [] - for input_var in candidate_inputs: - shape, scale, loc = best_params.loc[output_variable, input_var] - x0.extend([shape, scale, loc]) - bounds = [] - for input_var in candidate_inputs: - bounds.extend( - [(1.0, 300.0), (1e-5, 300.0), (0.0, 300.0)] - ) # shape, scale, loc - - # First, compute baseline R² using SISO-optimized params (x0) - # This ensures we never do worse than the initial guess - baseline_r2 = -joint_objective(x0, debug=True) - print(f" Baseline R² with SISO params: {baseline_r2:.4f}") - - # optimize - multivariable_iterations = max_iterations * len(candidate_inputs) - result = minimize( - joint_objective, - x0, - method="Nelder-Mead", - bounds=bounds, - options={"maxiter": multivariable_iterations, "disp": verbose}, - ) - # result = minimize(joint_objective, x0, method='L-BFGS-B', bounds=bounds, - # options={'maxiter': multivariable_iterations, 'disp': verbose}) - optimized_r2 = -result.fun - - # Use optimized params only if they improve on baseline, otherwise keep SISO params - if optimized_r2 >= baseline_r2: - optimized_params = result.x - print(f" Optimizer improved R² to {optimized_r2:.4f}") - else: - optimized_params = x0 - print( - f" Optimizer found worse R² ({optimized_r2:.4f}), keeping SISO params (R² = {baseline_r2:.4f})" - ) - - # extract best params - for i, input_var in enumerate(candidate_inputs): - shape = optimized_params[i * 3] - scale = optimized_params[i * 3 + 1] - loc = optimized_params[i * 3 + 2] - best_params.loc[output_variable, input_var] = (shape, scale, loc) - # compute final r2 with optimized params - transformed_inputs = pd.DataFrame(index=system_data.index) - for i, input_var in enumerate(candidate_inputs): - shape = optimized_params[i * 3] - scale = optimized_params[i * 3 + 1] - loc = optimized_params[i * 3 + 2] - shape_factors = pd.DataFrame(columns=[input_var], index=[1]) - shape_factors.loc[1, input_var] = shape - scale_factors = pd.DataFrame(columns=[input_var], index=[1]) - scale_factors.loc[1, input_var] = scale - loc_factors = pd.DataFrame(columns=[input_var], index=[1]) - loc_factors.loc[1, input_var] = loc - forcing_orig = system_data[[input_var]].copy() - transformed = transform_inputs( - shape_factors, - scale_factors, - loc_factors, - system_data.index, - forcing_orig, - ) - # Include BOTH original and transformed columns, consistent with SISO phase - transformed_inputs = pd.concat( - (transformed_inputs, transformed), axis="columns" - ) - feature_names = [output_variable] + list(transformed_inputs.columns) - model = ps.SINDy( - differentiation_method=ps.FiniteDifference(order=10, drop_endpoints=True), - feature_library=ps.PolynomialLibrary( - degree=2, include_bias=False, include_interaction=False - ), - optimizer=ps.optimizers.STLSQ(threshold=0, alpha=0), - ) - fit = model.fit( - x=system_data.loc[:, output_variable], - t=np.arange(0, len(system_data.index), 1), - u=transformed_inputs, - feature_names=feature_names, - ) - r2 = fit.score( - x=system_data.loc[:, output_variable], - t=np.arange(0, len(system_data.index), 1), - u=transformed_inputs, - ) - - print( - f"Testing inputs {candidate_inputs} for output {output_variable} -> r2 = {r2:.4f}" - ) - if ( - r2 > current_best_r2[output_variable] + 0.01 - ): # only keep it if it improves the r2 by at least 1% - # add a conditional here for reducing the number of components in the graph. if it doesn't connect things that were previously unconnected, we don't want it. - selected_inputs = candidate_inputs - current_best_r2[output_variable] = r2 - print( - f" Accepted new input {forcing_variable}, updated r2 = {current_best_r2[output_variable]:.4f}" - ) - edges.loc[output_variable, forcing_variable] = 1 - - # Update correlation-weighted R² for this output since we added a new input - # The while loop will re-sort at the next iteration - update_corr_weighted_r2(output_variable) - - else: - print(f" Rejected new input {forcing_variable}, r2 would be {r2:.4f}") - - # transpose edges to have from -> to convention - edges = edges.T - # earlier in the code we have dependent variables on the rows and independent on columns. - # that arrangement makes comparing the effect of potential inputs on each output easier. - # but for output, it's more intuitive to have from -> to convention, so we transpose before returning. - - return { - "edges": edges, - "best_params": best_params, - "r2_values": r2_values, - "lead_lag": lead_lag, - } - - -# this function takes in the system data, -# which columns are dependent and which are independent, -# as well as an optional constraint on the topology of the digraph -# we will return a digraph (not multidigraph as there are no parallel edges) as defined in https://networkx.org/documentation/stable/reference/classes/digraph.html -# we'll assume there are always self-loops (the derivative always depends on the current value of the variable) -# this will also be returned as an adjacency matrix -# this doesn't go all the way to turning the data into an LTI system. that will be another function that uses this one -def infer_causative_topology( # noqa: F811 - # type: ignore - system_data, - dependent_columns, - independent_columns, - graph_type="Weak-Conn", - verbose=False, - max_iter=250, - swmm=False, - method="sindy", # Changed default from "granger" to "sindy" - derivative=False, - sensor_locations=None, - init_neighbors=3, -): - """ - Infer causative topology from time series data using SINDy-based optimization. - - Args: - system_data: pd.DataFrame with time series data - dependent_columns: list of column names that are dependent variables - independent_columns: list of column names that are independent/forcing variables - graph_type: type of graph connectivity requirement ('Weak-Conn' or 'Strong-Conn') - verbose: whether to print detailed output - max_iter: maximum iterations for optimization - swmm: whether this is for SWMM/pystorms data - method: inference method ('sindy' is the only supported method now) - derivative: whether to use derivative of response - sensor_locations: optional dict mapping column names to {"lat": float, "lon": float} - init_neighbors: initial number of nearest neighbors to evaluate when sensor_locations is provided (default: 3) - - Returns: - dict with keys: "edges", "best_params", "r2_values", "lead_lag" - - edges: DataFrame adjacency matrix (from -> to convention) - - best_params: DataFrame of transformation parameters (shape, scale, loc) - - r2_values: DataFrame of R^2 values for each potential edge - - lead_lag: DataFrame of lead/lag values (positive = forcing leads response) - """ - import warnings - - # Handle deprecated methods - if method in ("granger", "ccm", "transfer_entropy"): - warnings.warn( - f"Method '{method}' is deprecated. The Granger causality, CCM, and " - "Transfer Entropy methods have been replaced by the improved SINDy-based " - "topology inference (method='sindy'), which provides significantly better " - "results. Please use method='sindy' (the new default).", - DeprecationWarning, - stacklevel=2, - ) - # Fall back to new method - method = "sindy" - - if swmm: - # do the same for dependent_columns and independent_columns - dependent_columns = [str(col) for col in dependent_columns] - independent_columns = [str(col) for col in independent_columns] - # do the same for the columns of system_data - system_data.columns = system_data.columns.astype(str) - - # Import and use the new SINDy-based topology inference - # (using our local implementation) - result = find_topology_no_geo( - system_data=system_data, - dependent_columns=dependent_columns, - independent_columns=independent_columns, - sensor_locations=sensor_locations, - max_iterations=max_iter, - graph_type=graph_type, - verbose=verbose, - init_neighbors=init_neighbors, - ) - # Convert result to match expected return format for backward compatibility - # The new method returns edges in from->to convention (transposed from old) - edges = result["edges"] - _ = result["best_params"] - r2_values = result["r2_values"] - _ = result["lead_lag"] - - # For backward compatibility with code expecting (causative_topo, total_graph) tuple - # causative_topo: 'd' for directed edge, 'n' for no edge - # total_graph: numeric weights (R² values) - causative_topo = pd.DataFrame( - index=dependent_columns, columns=system_data.columns - ).fillna("n") - total_graph = pd.DataFrame( - index=dependent_columns, columns=system_data.columns, dtype=float - ).fillna(0.0) - - # Fill in the edges from the result - # edges is in from->to convention (row=from, col=to) - # causative_topo expects row=dependent (to), col=forcing (from) - for dep_col in dependent_columns: - for forcing_col in system_data.columns: - if edges.loc[forcing_col, dep_col] == 1: # from forcing_col -> to dep_col - causative_topo.loc[dep_col, forcing_col] = "d" - total_graph.loc[dep_col, forcing_col] = r2_values.loc[ - dep_col, forcing_col - ] - - return causative_topo, total_graph - - -def find_topology( # noqa: F811 - # type: ignore - system_data, - dependent_columns, - independent_columns, - method="ccm", - graph_type="Weak-Conn", - verbose=False, -): - """ - DEPRECATED: This function has been replaced by the improved SINDy-based - topology inference via the local SINDy-based implementation. - - Please use infer_causative_topology(method='sindy') or directly import - use infer_causative_topology(). - """ - import warnings - - warnings.warn( - "find_topology() is deprecated. Use infer_causative_topology(method='sindy') " - "or use infer_causative_topology() instead.", - DeprecationWarning, - stacklevel=2, - ) - # Delegate to the new implementation - return infer_causative_topology( - system_data=system_data, - dependent_columns=dependent_columns, - independent_columns=independent_columns, - graph_type=graph_type, - verbose=verbose, - method="sindy", - ) diff --git a/modpods/__init__.py b/modpods/__init__.py new file mode 100644 index 0000000..e0d5ecc --- /dev/null +++ b/modpods/__init__.py @@ -0,0 +1,18 @@ +from .lti import lti_from_gamma, lti_system_gen +from .model import SINDY_delays_MI +from .predict import delay_io_predict +from .topology import find_topology_no_geo, infer_causative_topology +from .train import delay_io_train +from .transforms import TransformCache, transform_inputs + +__all__ = [ + "TransformCache", + "transform_inputs", + "delay_io_train", + "SINDY_delays_MI", + "delay_io_predict", + "lti_from_gamma", + "lti_system_gen", + "find_topology_no_geo", + "infer_causative_topology", +] diff --git a/modpods/lti.py b/modpods/lti.py new file mode 100644 index 0000000..6c0b367 --- /dev/null +++ b/modpods/lti.py @@ -0,0 +1,695 @@ +from typing import Any + +import control +import numpy as np +import pandas as pd +import pysindy as ps +import scipy.stats as stats +from pysindy.optimizers._constrained_sr3 import ConstrainedSR3 as _ConstrainedSR3 + +from .train import delay_io_train + + +def lti_from_gamma( + shape, + scale, + location, + dt=0, + desired_NSE=0.999, + verbose=False, + max_state_dim=50, + max_iterations=200, + max_pole_speed=5, + min_pole_speed=0.01, +): + # a pole of speed -5 decays to less than 1% of it's value after one timestep + # a pole of speed -0.01 decays to more than 99% of it's value after one timestep + + # i've assumed here that gamma pdf is defined the same as in matlab + # if that's not true testing will show it soon enough + t50 = shape * scale + location # center of mass + skewness = 2 / np.sqrt(shape) + total_time_base = ( + 2 * t50 + ) # not that this contains the full shape, but if we fit this much of the curve perfectly we'll be close enough + # resolution = (t50)/((skewness + location)) # make this coarser for faster debugging + resolution = (t50) / (10 * (skewness + location)) # production version + + # resolution = 1/ skewness + decay_rate = 1 / resolution + decay_rate = np.clip(decay_rate, min_pole_speed, max_pole_speed) + state_dim = int( + np.floor(total_time_base * decay_rate) + ) # this keeps the time base fixed for a given decay rate + if state_dim > max_state_dim: + state_dim = max_state_dim + decay_rate = state_dim / total_time_base + resolution = 1 / decay_rate + if state_dim < 1: + state_dim = 1 + decay_rate = state_dim / total_time_base + resolution = 1 / decay_rate + + decay_rate = np.clip(decay_rate, min_pole_speed, max_pole_speed) + + if verbose: + print("state dimension is ", state_dim) + print("decay rate is ", decay_rate) + print("total time base is ", total_time_base) + print("resolution is", resolution) + + # make the timestep one so that the relative error is correct (dt too small makes error bigger than written) + # t = np.linspace(0,3*total_time_base,1000) + # desired_error = desired_error / dt + t = np.linspace(0, 2 * total_time_base, num=200) + + # if verbose: + # print("dt is ",dt) + # print("scaled desired error is ",desired_error) + + gam = stats.gamma.pdf(t, shape, location, scale) + + # A is a cascade with the appropriate decay rate + A = decay_rate * np.diag(np.ones((state_dim - 1)), -1) - decay_rate * np.diag( + np.ones((state_dim)), 0 + ) + # influence enters at the top state only + B = np.concatenate((np.ones((1, 1)), np.zeros((state_dim - 1, 1)))) + # contributions of states to the output will be scaled to match the gamma distribution + C = np.ones((1, state_dim)) * max(gam) + lti_sys = control.ss(A, B, C, 0) + + lti_approx = control.impulse_response(lti_sys, t) + NSE = 1 - ( + np.sum(np.square(gam - lti_approx.y)) / np.sum(np.square(gam - np.mean(gam))) + ) + # if NSE is nan, set to -10e6 + if np.isnan(NSE): + NSE = -10e6 + + if verbose: + print("initial NSE") + print(NSE) + print("desired NSE") + print(desired_NSE) + + iterations = 0 + + speeds = [10, 5, 2, 1.1, 1.05, 1.01, 1.001] + speed_idx = 0 + leap = speeds[speed_idx] + # the area under the curve is normalized to be one. so rather than basing our desired error off the + # max of the distribution, it might be better to make it a percentage error, one percent or five percent + while NSE < desired_NSE and iterations < max_iterations: + + og_was_best = ( + True # start each iteration assuming that the original is the best + ) + # search across the C vector + for i in range( + C.shape[1] - 1, int(-1), int(-1) + ): # accross the columns # start at the end and come back + # for i in range(int(0),C.shape[1],int(1)): # accross the columns, start at the beginning and go forward + + og_approx = control.ss(A, B, C, 0) + og_y = np.ndarray.flatten(control.impulse_response(og_approx, t).y) + og_error = np.sum(np.abs(gam - og_y)) + og_NSE = 1 - (np.sum((gam - og_y) ** 2) / np.sum((gam - np.mean(gam)) ** 2)) + + Ctwice = np.array(C, copy=True) + Ctwice[0, i] = leap * C[0, i] + twice_approx = control.ss(A, B, Ctwice, 0) + twice_y = np.ndarray.flatten(control.impulse_response(twice_approx, t).y) + twice_NSE = 1 - ( + np.sum((gam - twice_y) ** 2) / np.sum((gam - np.mean(gam)) ** 2) + ) + + Chalf = np.array(C, copy=True) + Chalf[0, i] = (1 / leap) * C[0, i] + half_approx = control.ss(A, B, Chalf, 0) + half_y = np.ndarray.flatten(control.impulse_response(half_approx, t).y) + half_NSE = 1 - ( + np.sum((gam - half_y) ** 2) / np.sum((gam - np.mean(gam)) ** 2) + ) + faster = np.array(A, copy=True) + faster[i, i] = A[i, i] * leap # faster decay + if abs(faster[i, i]) < abs(max_pole_speed): + if ( + i > 0 + ): # first reservoir doesn't receive contribution from another reservoir. want to keep B at 1 for scaling + faster[i, i - 1] = A[i, i - 1] * leap # faster rise + faster_approx = control.ss(faster, B, C, 0) + faster_y = np.ndarray.flatten( + control.impulse_response(faster_approx, t).y + ) + faster_NSE = 1 - ( + np.sum((gam - faster_y) ** 2) / np.sum((gam - np.mean(gam)) ** 2) + ) + else: + faster_NSE = -10e6 # disallowed because the pole is too fast + + slower = np.array(A, copy=True) + slower[i, i] = A[i, i] / leap # slower decay + if abs(slower[i, i]) > abs(min_pole_speed): + if i > 0: + slower[i, i - 1] = A[i, i - 1] / leap # slower rise + slower_approx = control.ss(slower, B, C, 0) + slower_y = np.ndarray.flatten( + control.impulse_response(slower_approx, t).y + ) + slower_NSE = 1 - ( + np.sum((gam - slower_y) ** 2) / np.sum((gam - np.mean(gam)) ** 2) + ) + else: + slower_NSE = -10e6 # disallowed because the pole is too slow + + # all_errors = [og_error, twice_error, half_error, faster_error, slower_error] + all_NSE = [ + og_NSE, + twice_NSE, + half_NSE, + faster_NSE, + slower_NSE, + ] + + if twice_NSE >= max(all_NSE) and twice_NSE > og_NSE: + C = Ctwice + if twice_NSE > 1.001 * og_NSE: # an appreciable difference + og_was_best = False # did we change something this iteration? + elif half_NSE >= max(all_NSE) and half_NSE > og_NSE: + C = Chalf + if half_NSE > 1.001 * og_NSE: # an appreciable difference + og_was_best = False # did we change something this iteration? + + elif slower_NSE >= max(all_NSE) and slower_NSE > og_NSE: + A = slower + if slower_NSE > 1.001 * og_NSE: # an appreciable difference + og_was_best = False # did we change something this iteration? + elif faster_NSE >= max(all_NSE) and faster_NSE > og_NSE: + A = faster + if faster_NSE > 1.001 * og_NSE: # an appreciable difference + og_was_best = False # did we change something this iteration? + + NSE = og_NSE + error = og_error + iterations += 1 # this shouldn't be the termination condition unless the resolution is too coarse + # normally the optimization should exit because the leap has become too small + if ( + og_was_best + ): # the original was the best, so we're going to tighten up the optimization + speed_idx += 1 + if speed_idx > len(speeds) - 1: + break # we're done + leap = speeds[speed_idx] + # print the iteration count every ten + # comment out for production + if iterations % 2 == 0 and verbose: + print("iterations = ", iterations) + print("error = ", error) + print("NSE = ", NSE) + print("leap = ", leap) + + lti_approx = control.ss(A, B, C, 0) + y = np.ndarray.flatten(control.impulse_response(og_approx, t).y) + error = np.sum(np.abs(gam - og_y)) + print("LTI_from_gamma final NSE") + print(NSE) + if verbose: + print("final system\n") + print("A") + print(A) + print("B") + print(B) + print("C") + print(C) + + print("\nfinal error") + print(error) + + # are any of the final eigenvalues outside the bounds specified? + E = np.linalg.eigvals(A) + if np.any(np.abs(E) > max_pole_speed) or np.any(np.abs(E) < min_pole_speed): + print("WARNING: final eigenvalues are outside the bounds specified") + + return { + "lti_approx": lti_approx, + "lti_approx_output": y, + "error": error, + "t": t, + "gamma_pdf": gam, + } + + +# this function takes the system data and the causative topology and returns an LTI system +# if the causative topology isn't already defined, it needs to be created using infer_causative_topology +def lti_system_gen( + causative_topology, + system_data, + independent_columns, + dependent_columns, + max_iter=250, + swmm=False, + bibo_stable=False, + max_transition_state_dim=50, + max_transforms=1, + early_stopping_threshold=0.005, +): + + # cast the columns and indices of causative_topology to strings so sindy can run properly + # We need the tuples to link the columns in system_data to the object names in the swmm model + # so we'll cast these back to tuples once we're done + if swmm: + causative_topology.columns = causative_topology.columns.astype(str) + causative_topology.index = causative_topology.index.astype(str) + + print("causative topology \n") + print(causative_topology.index) + print(causative_topology.columns) + + # do the same for dependent_columns and independent_columns + dependent_columns = [str(col) for col in dependent_columns] + independent_columns = [str(col) for col in independent_columns] + print(dependent_columns) + print(independent_columns) + + # do the same for the columns of system_data + system_data.columns = system_data.columns.astype(str) + print(system_data.columns) + + A = pd.DataFrame(index=dependent_columns, columns=dependent_columns) + B = pd.DataFrame(index=dependent_columns, columns=independent_columns) + C = pd.DataFrame(index=dependent_columns, columns=dependent_columns) + C.loc[:, :] = np.diag( + np.ones(len(dependent_columns)) + ) # these are the states which are observable + + # copy the corresponding entries from the causative topology into B + for row in B.index: + for col in B.columns: + B.loc[row, col] = causative_topology.loc[row, col] + # and into A + for row in A.index: + for col in A.columns: + A.loc[row, col] = causative_topology.loc[row, col] + + print("A") + print(A) + print("B") + print(B) + print("C") + print(C) + # use transform_only when calling delay_io_train to only train transfomrations for connections marked "d" + # train a MISO model for each output + delay_models: dict = {key: None for key in dependent_columns} + + for row in A.index: + immediate_forcing = [] + delayed_forcing = [] + for col in A.columns: + if col == row: + continue # don't need to include the output state as a forcing variable. it's already included by default + if A[col][row] == "d": + delayed_forcing.append(col) + elif A[col][row] == "i": + immediate_forcing.append(col) + for col in B.columns: + if B[col][row] == "d": + delayed_forcing.append(col) + elif B[col][row] == "i": + immediate_forcing.append(col) + # make total_forcing the union of immediate and delayed forcing + total_forcing = immediate_forcing + delayed_forcing + feature_names = [row] + total_forcing + if delayed_forcing: + print( + "training delayed model for ", + row, + " with forcing ", + total_forcing, + "\n", + ) + delay_models[row] = delay_io_train( + system_data, + [row], + total_forcing, + transform_only=delayed_forcing, + max_transforms=max_transforms, + poly_order=1, + max_iter=max_iter, + verbose=False, + bibo_stable=bibo_stable, + ) + # we'll parse this delayed causation into the matrices A, B, and C later + else: + ####### TODO: incorporate bibo stability constraint into instantaneous fits ######## + print( + "training immediate model for ", + row, + " with forcing ", + total_forcing, + "\n", + ) + delay_models[row] = None + # we can put immediate causation into the matrices A, B, and C now + + if bibo_stable: # negative autocorrelatoin + # Figure out how many library features there will be + library = ps.PolynomialLibrary( + degree=1, include_bias=False, include_interaction=False + ) + # fit on a dummy (2, n_features) array; 2 rows is the minimum pysindy requires + library.fit(np.zeros((2, len(feature_names)))) + n_features = library.n_output_features_ + constraint_rhs = np.zeros(1) + # one row per constraint, one column per coefficient + constraint_lhs = np.zeros((1, n_features)) + + # constrain the highest order output autocorrelation to be negative + # this indexing is only right for include_interaction=False, include_bias=False, and pure polynomial library + # for more complex libraries, some conditional logic will be needed to grab the right column + constraint_lhs[:, 0] = 1 + + model = ps.SINDy( + differentiation_method=ps.FiniteDifference(), + feature_library=ps.PolynomialLibrary( + degree=1, include_bias=False, include_interaction=False + ), + optimizer=_ConstrainedSR3( + reg_weight_lam=0, + regularizer="l2", + constraint_lhs=constraint_lhs, + constraint_rhs=constraint_rhs, + inequality_constraints=True, + ), + ) + + else: # unoconstrained + model = ps.SINDy( + differentiation_method=ps.FiniteDifference( + order=10, drop_endpoints=True + ), + feature_library=ps.PolynomialLibrary( + degree=1, include_bias=False, include_interaction=False + ), + optimizer=ps.optimizers.STLSQ(threshold=0, alpha=0), + ) + if system_data.loc[ + :, immediate_forcing + ].empty: # the subsystem is autonomous + instant_fit = model.fit( + x=system_data.loc[:, row], t=np.arange(0, len(system_data.index), 1) + ) + instant_fit.print(precision=3) + print( + "Training r2 = ", + instant_fit.score( + x=system_data.loc[:, row], + t=np.arange(0, len(system_data.index), 1), + ), + ) + print(instant_fit.coefficients()) + else: # there is some forcing + instant_fit = model.fit( + x=system_data.loc[:, row], + t=np.arange(0, len(system_data.index), 1), + u=system_data.loc[:, immediate_forcing], + ) + instant_fit.print(precision=3) + print( + "Training r2 = ", + instant_fit.score( + x=system_data.loc[:, row], + t=np.arange(0, len(system_data.index), 1), + u=system_data.loc[:, immediate_forcing], + ), + ) + print(instant_fit.coefficients()) + for idx in range(len(feature_names)): + if feature_names[idx] in A.columns: + A.loc[row, feature_names[idx]] = instant_fit.coefficients()[0][idx] + elif feature_names[idx] in B.columns: + B.loc[row, feature_names[idx]] = instant_fit.coefficients()[0][idx] + else: + print("couldn't find a column for ", feature_names[idx]) + + original_A = A.copy(deep=True) + # now, parse the delay models into the A, B, and C matrices + for row in original_A.index: + if delay_models[row] is None: + pass + else: # we want the model with the most transformations where the last trnasformation added at least 0.5% to the R2 score + for num_transforms in range(1, max_transforms + 1): + if num_transforms == 1: + optimal_number_transforms = num_transforms + elif ( + delay_models[row][num_transforms]["final_model"]["error_metrics"][ + "r2" + ] + - delay_models[row][num_transforms - 1]["final_model"][ + "error_metrics" + ]["r2"] + < early_stopping_threshold + ): + optimal_number_transforms = num_transforms - 1 + break # improvement is too small to justify additional complexity + else: + optimal_number_transforms = ( + num_transforms # the most recent one was worth it + ) + + transformation_approximations: dict[str, Any] = { + transform_key: {} + for transform_key in delay_models[row][optimal_number_transforms][ + "shape_factors" + ].columns + } + for transform_key in transformation_approximations.keys(): # which input + for idx in range( + 1, optimal_number_transforms + 1 + ): # which transformation + print("variable = ", transform_key, ", transformation = ", idx) + delay_models[row][optimal_number_transforms]["final_model"][ + "model" + ].print(precision=5) + shape = delay_models[row][optimal_number_transforms][ + "shape_factors" + ].loc[idx, transform_key] + scale = delay_models[row][optimal_number_transforms][ + "scale_factors" + ].loc[idx, transform_key] + loc = delay_models[row][optimal_number_transforms][ + "loc_factors" + ].loc[idx, transform_key] + # this will get overwritten if we use more than one transformation per input. i think that's okay. + transformation_approximations[transform_key] = lti_from_gamma( + shape, scale, loc, max_state_dim=max_transition_state_dim + ) + + lti_result = transformation_approximations[transform_key] + Agam = lti_result["lti_approx"].A + Bgam = lti_result[ + "lti_approx" + ].B # only entry is unit impulse at top state + Cgam = lti_result["lti_approx"].C + + tr_string = str("_tr_" + str(idx)) + + # Cgam needs to be scaled by the coefficient the forcing term had in the delay model + coefficients = { + coef_key: None + for coef_key in delay_models[row][optimal_number_transforms][ + "final_model" + ]["model"].feature_names + } + for coef_key in coefficients.keys(): + coef_index = delay_models[row][optimal_number_transforms][ + "final_model" + ]["model"].feature_names.index(coef_key) + coefficients[coef_key] = delay_models[row][ + optimal_number_transforms + ]["final_model"]["model"].coefficients()[0][coef_index] + if tr_string in coef_key and coef_key.replace( + tr_string, "" + ) == transform_key.replace(tr_string, ""): + Cgam = Cgam * coefficients[coef_key] # scaling + else: # these are the immediate effects, insert them now + if coef_key in A.columns: + A.loc[row, coef_key] = coefficients[coef_key] + elif coef_key in B.columns: + B.loc[row, coef_key] = coefficients[coef_key] + + Agam_index = [] + for agam_idx in range(Agam.shape[0]): + Agam_index.append( + transform_key.replace(tr_string, "") + + "->" + + row + + tr_string + + "_" + + str(agam_idx) + ) + Agam = pd.DataFrame(Agam, index=Agam_index, columns=Agam_index) + Bgam = pd.DataFrame( + Bgam, + index=Agam_index, + columns=[transform_key.replace(tr_string, "")], + ) + Cgam = pd.DataFrame(Cgam, index=[row], columns=Agam_index) + # insert these into the A, B, and C matrices + # for Agam, the insertion row is immediately after the source (key) + # the insertion column is also immediately after the source (key) + + ### everything below this point is garbage. not performing at all as desired at the moment + + # first need to create space for the new rows and columns + # create before_index and after_index variables, which record the parts of the index of A that occur before and after row + before_index = [] + if ( + transform_key.replace(tr_string, "") not in A.index + ): # it's one of the forcing terms. put it in at the beginning + after_index = list( + A.index + ) # it's a forcing variable, so we don't want it in the newA index + else: # it is a state variable + before_index = list( + A.index[ + : A.index.get_loc(transform_key.replace(tr_string, "")) + ] + ) + + after_index = list( + A.index[ + A.index.get_loc(transform_key.replace(tr_string, "")) + + 1 : + ] + ) + + # if transform_key.replace("_tr_1","") in A.index: # the transform key refers to a state (x) + if transform_key.replace(tr_string, "") in A.index: + # states = before_index + [transform_key.replace("_tr_1","")] + Agam_index + after_index # state dim expands by the number of rows in Agam + states = ( + before_index + + [transform_key.replace(tr_string, "")] + + Agam_index + + after_index + ) # state dim expands by the number of rows in Agam + # include the current transform key in A because it's a state variable + # elif transform_key.replace("_tr_1","") in B.columns: # the transform key refers to a control input (u) + elif ( + transform_key.replace(tr_string, "") in B.columns + ): # the transform key refers to a control input (u) + states = ( + before_index + Agam_index + after_index + ) # state dim expands by the number of rows in Agam + # don't include the current transform key in A because it's a control input, not a state variable + + newA = pd.DataFrame(index=states, columns=states) + newB = pd.DataFrame( + index=states, columns=B.columns + ) # input dim remains consistent (columns of B) + newC = pd.DataFrame( + index=C.index, columns=states + ) # output dim remains consistent (rows of C) + + # fill in newA with the corresponding entries from A + for idx in newA.index: + for col in newA.columns: + if ( + idx in A.index and col in A.columns + ): # if it's in the original A matrix, copy it over + newA.loc[idx, col] = A.loc[idx, col] + if ( + idx in Agam.index and col in Agam.columns + ): # if it's in Agam, copy it over + newA.loc[idx, col] = Agam.loc[idx, col] + if ( + idx in Bgam.index and col in Bgam.columns + ): # the input to the cascade is a state + newA.loc[idx, col] = Bgam.loc[idx, col] + + for idx in newB.index: + for col in newB.columns: + if ( + idx in B.index and col in B.columns + ): # if it's in the original B matrix, copy it over + newB.loc[idx, col] = B.loc[idx, col] + if ( + idx in Bgam.index and col in Bgam.columns + ): # the input to the cascade is a forcing term + newB.loc[idx, col] = Bgam.loc[idx, col] + + for idx in newC.index: + for col in newC.columns: + if ( + idx in C.index and col in C.columns + ): # if it's in the original C matrix, copy it over + newC.loc[idx, col] = C.loc[idx, col] + if ( + idx in Cgam.index and col in Cgam.columns + ): # outputs from the cascades + newA.loc[idx, col] = Cgam.loc[idx, col] + + # copy over + A = newA.copy(deep=True) + B = newB.copy(deep=True) + C = newC.copy(deep=True) + + A.replace("n", 0.0, inplace=True) + B.replace("n", 0.0, inplace=True) + C.replace("n", 0.0, inplace=True) + + if swmm: + pass + ############# + # TODO: cast strings back to tuples in the indices and columns + ############# + # cast the index and columns of causative_topology to tuples. they'll be of the form "(X,Y)" + + # do the same for dependent_columns and independent_columns + + # do the same for the columns of system_data + + A = A.apply(pd.to_numeric, errors="coerce").fillna(0.0) + B = B.apply(pd.to_numeric, errors="coerce").fillna(0.0) + C = C.apply(pd.to_numeric, errors="coerce").fillna(0.0) + + # if bibo_stable is specified and A not hurwitz, make A hurwitz by defining A' = A - I*max(real(eig(A))) + # this will gaurantee stability (max eigenvalue will have real part < 0) + if bibo_stable: + orig_eigs, _ = np.linalg.eig(A) + if any(np.real(orig_eigs) > 0): + print("stabilizing unstable plant by subtracting I*max(real(eig)) from A") + epsilon = 10e-4 + A_stab = A - np.eye(len(A)) * (1 + epsilon) * max( + np.real(orig_eigs) + ) # add factor of (1+epsilon) for stability, not marginal stabilty + stab_eigs, _ = np.linalg.eig(A_stab) + A = A_stab.copy(deep=True) + + # sindy will scale the coefficients according to the timestep if the index is numeric + # so the whole system needs to be scaled by the timestep if its numeric + try: + pd.to_numeric( + system_data.index, errors="raise" + ) # can the index be converted to a numeric type? + dt = system_data.index.values[1] - system_data.index.values[0] + A = A / dt + B = B / dt + C = C # what we observe doesn't need to be adjusted, just the dynamics + print("system response data index converted to numeric type. dt = ") + print(dt) + except Exception as e: + print(e) + dt = None + + # cast all of A, B, and C to type float (integers cause issues with LQR / LQE calculations) + A = A.astype(float) + B = B.astype(float) + C = C.astype(float) + + lti_sys = control.ss( + A, B, C, 0, inputs=B.columns, outputs=C.index, states=A.columns + ) + + # returning the matrices too because control.ss strips the labels from the pandas dataframes and stores them as numpy matrices + return {"system": lti_sys, "A": A, "B": B, "C": C} diff --git a/modpods/metrics.py b/modpods/metrics.py new file mode 100644 index 0000000..62749a5 --- /dev/null +++ b/modpods/metrics.py @@ -0,0 +1,30 @@ +import numpy as np + + +def compute_basic_metrics(y_true, y_pred): + """Compute common error metrics between true and predicted values. + + Args: + y_true: array of observed values + y_pred: array of predicted values + + Returns: + dict with keys: "mae", "rmse", "nse", "alpha", "beta" + """ + error = y_true - y_pred + mae = float(np.mean(np.abs(error))) + rmse = float(np.sqrt(np.mean(error**2))) + nse = float( + 1 + - np.sum(error**2) + / np.sum((y_true - np.mean(y_true)) ** 2) + ) + alpha = float(np.std(y_pred) / np.std(y_true)) + beta = float(np.mean(y_pred) / np.mean(y_true)) + return { + "mae": mae, + "rmse": rmse, + "nse": nse, + "alpha": alpha, + "beta": beta, + } diff --git a/modpods/model.py b/modpods/model.py new file mode 100644 index 0000000..6d380ec --- /dev/null +++ b/modpods/model.py @@ -0,0 +1,509 @@ + +import numpy as np +import pandas as pd +import pysindy as ps +from pysindy.optimizers._constrained_sr3 import ConstrainedSR3 as _ConstrainedSR3 + +from .metrics import compute_basic_metrics +from .transforms import transform_inputs + + +def SINDY_delays_MI( + shape_factors, + scale_factors, + loc_factors, + index, + forcing, + response, + final_run, + poly_degree, + include_bias, + include_interaction, + windup_timesteps, + bibo_stable=False, + transform_dependent=False, + transform_only=None, + forcing_coef_constraints=None, + transform_cache=None, +): + if transform_only is not None: + transformed_forcing = transform_inputs( + shape_factors, + scale_factors, + loc_factors, + index, + forcing.loc[:, transform_only], + cache=transform_cache, + ) + untransformed_forcing = forcing.drop(columns=transform_only) + # combine forcing and transformed forcing column-wise + forcing = pd.concat( + (untransformed_forcing, transformed_forcing), axis="columns" + ) + else: + forcing = transform_inputs( + shape_factors, + scale_factors, + loc_factors, + index, + forcing, + cache=transform_cache, + ) + + feature_names = response.columns.tolist() + forcing.columns.tolist() + + # SINDy + if ( + not bibo_stable and forcing_coef_constraints is None + ): # no constraints, normal mode + model = ps.SINDy( + differentiation_method=ps.FiniteDifference(), + feature_library=ps.PolynomialLibrary( + degree=poly_degree, + include_bias=include_bias, + include_interaction=include_interaction, + ), + optimizer=ps.STLSQ(threshold=0), + ) + elif forcing_coef_constraints is not None and not bibo_stable: + library = ps.PolynomialLibrary( + degree=poly_degree, + include_bias=include_bias, + include_interaction=include_interaction, + ) + total_train = pd.concat((response, forcing), axis="columns") + library.fit([ps.AxesArray(total_train, {"ax_sample": 0, "ax_coord": 1})]) + n_features = library.n_output_features_ + n_targets = len(response.columns) + constraint_rhs = np.zeros((n_features,)) # every feature is constrained + # one row per constraint, one column per coefficient + constraint_lhs = np.zeros((n_features, n_targets * n_features)) + + # now implement the forcing coefficient constraints + for i, col in enumerate(feature_names): + for key in forcing_coef_constraints.keys(): + if key in col: + constraint_lhs[i, i] = -forcing_coef_constraints[key] + # invert the sign because the eqn is written as "leq 0" + + model = ps.SINDy( + differentiation_method=ps.FiniteDifference(), + feature_library=ps.PolynomialLibrary( + degree=poly_degree, + include_bias=include_bias, + include_interaction=include_interaction, + ), + optimizer=_ConstrainedSR3( + reg_weight_lam=0, + regularizer="l2", + constraint_lhs=constraint_lhs, + constraint_rhs=constraint_rhs, + inequality_constraints=True, + ), + ) + elif ( + bibo_stable + ): # highest order output autocorrelation is constrained to be negative + # Figure out how many library features there will be + library = ps.PolynomialLibrary( + degree=poly_degree, + include_bias=include_bias, + include_interaction=include_interaction, + ) + total_train = pd.concat((response, forcing), axis="columns") + library.fit([ps.AxesArray(total_train, {"ax_sample": 0, "ax_coord": 1})]) + n_features = library.n_output_features_ + # print(f"Features ({n_features}):", library.get_feature_names(input_features=total_train.columns)) + feature_names = library.get_feature_names(input_features=total_train.columns) + # Set constraints + n_targets = total_train.shape[ + 1 + ] # not sure what targets means after reading through the pysindy docs + # print("n_targets") + # print(n_targets) + constraint_rhs = np.zeros((len(response.columns), 1)) + # one row per constraint, one column per coefficient + constraint_lhs = np.zeros((len(response.columns), n_features)) + + # print(constraint_rhs) + # print(constraint_lhs) + # constrain the highest order output autocorrelation to be negative + # this indexing is only right for include_interaction=False, include_bias=False, and pure polynomial library + # for more complex libraries, some conditional logic will be needed to grab the right column + constraint_lhs[ + :, -len(forcing.columns) - len(response.columns) : -len(forcing.columns) + ] = 1 + # leq 0 + # print("constraint lhs") + # print(constraint_lhs) + + # forcing_coef_constraints only implemented for bibo stable MISO models right now + if forcing_coef_constraints is not None: + n_targets = len(response.columns) + constraint_rhs = np.zeros((n_features,)) # every feature is constrained + # one row per constraint, one column per coefficient + constraint_lhs = np.zeros((n_features, n_targets * n_features)) + # bibo stability, set the highest order output autocorrelation to be negative for each response variable + # the index corresponds to the last entry in "feature_names" which includes the name of the response column + highest_power_col_idx = 0 + for i, col in enumerate(feature_names): + if response.columns[0] in col: + highest_power_col_idx = i + constraint_lhs[0, highest_power_col_idx] = ( + 1 # first row, highest power of the response variable + ) + + # now implement the forcing coefficient constraints + for i, col in enumerate(feature_names): + for key in forcing_coef_constraints.keys(): + if key in col: + constraint_lhs[i, i] = -forcing_coef_constraints[key] + # invert the sign because the eqn is written as "leq 0" + + # constrain the highest order output autocorrelation to be negative + # this indexing is only right for include_interaction=False, include_bias=False, and pure polynomial library + # for more complex libraries, some conditional logic will be needed to grab the right column + # constraint_lhs[:n_targets,-len(forcing.columns)-len(response.columns):-len(forcing.columns)] = 1 + + model = ps.SINDy( + differentiation_method=ps.FiniteDifference(), + feature_library=ps.PolynomialLibrary( + degree=poly_degree, + include_bias=include_bias, + include_interaction=include_interaction, + ), + optimizer=_ConstrainedSR3( + reg_weight_lam=0, + regularizer="l2", + constraint_lhs=constraint_lhs, + constraint_rhs=constraint_rhs, + inequality_constraints=True, + ), + ) + if transform_dependent: + # combine response and forcing into one dataframe + total_train = pd.concat((response, forcing), axis="columns") + total_train = transform_inputs( + shape_factors, scale_factors, loc_factors, index, total_train + ) + # remove the columns in total_train that are already in response (just want to keep the transformed forcing) + total_train = total_train.drop(columns=response.columns) + feature_names = response.columns.tolist() + total_train.columns.tolist() + + # need to add constraints such that variables don't depend on their own past values (but they can have autocorrelations) + + library = ps.PolynomialLibrary( + degree=poly_degree, + include_bias=include_bias, + include_interaction=include_interaction, + ) + library_terms = pd.concat((total_train, response), axis="columns") + library.fit([ps.AxesArray(library_terms, {"ax_sample": 0, "ax_coord": 1})]) + n_features = library.n_output_features_ + # print(f"Features ({n_features}):", library.get_feature_names()) + # Set constraints + n_targets = response.shape[ + 1 + ] # not sure what targets means after reading through the pysindy docs + + constraint_rhs = np.zeros((n_targets,)) + # one row per constraint, one column per coefficient + constraint_lhs = np.zeros((n_targets, n_features * n_targets)) + # for bibo stability, starting guess is that each dependent variable is negatively autocorrelated and depends on no other variable + if bibo_stable: + initial_guess = np.zeros((n_targets, n_features)) + for idx in range(0, n_targets): + initial_guess[idx, idx] = -1 + else: + initial_guess = None + # print(constraint_rhs) + # print(constraint_lhs) + # set the coefficient on a variable's own transformed value to 0 + for idx in range(0, n_targets): + constraint_lhs[idx, (idx + 1) * n_features - n_targets + idx] = 1 + + # print("constraint lhs") + # print(constraint_lhs) + + model = ps.SINDy( + differentiation_method=ps.FiniteDifference(), + feature_library=library, + optimizer=_ConstrainedSR3( + reg_weight_lam=0, + regularizer="l0", + relax_coeff_nu=10e9, + initial_guess=initial_guess, + constraint_lhs=constraint_lhs, + constraint_rhs=constraint_rhs, + inequality_constraints=False, + max_iter=10000, + ), + ) + + try: + # windup latent states (if your windup is too long, this will error) + model.fit( + response.values[windup_timesteps:, :], + t=np.arange(0, len(index), 1)[windup_timesteps:], + u=total_train.values[windup_timesteps:, :], + ) + r2 = model.score( + response.values[windup_timesteps:, :], + t=np.arange(0, len(index), 1)[windup_timesteps:], + u=total_train.values[windup_timesteps:, :], + ) # training data score + except Exception as e: # and print the exception + print("Exception in model fitting, returning r2=-1\n") + print(e) + error_metrics = { + "MAE": [False], + "RMSE": [False], + "NSE": [False], + "alpha": [False], + "beta": [False], + "HFV": [False], + "HFV10": [False], + "LFV": [False], + "FDC": [False], + "r2": -1, + } + return { + "error_metrics": error_metrics, + "model": None, + "simulated": False, + "response": response, + "forcing": forcing, + "index": index, + "diverged": False, + } + + else: + try: + # windup latent states (if your windup is too long, this will error) + model.fit( + response.values[windup_timesteps:, :], + t=np.arange(0, len(index), 1)[windup_timesteps:], + u=forcing.values[windup_timesteps:, :], + ) + r2 = model.score( + response.values[windup_timesteps:, :], + t=np.arange(0, len(index), 1)[windup_timesteps:], + u=forcing.values[windup_timesteps:, :], + ) # training data score + except Exception as e: # and print the exception + print("Exception in model fitting, returning r2=-1\n") + print(e) + error_metrics = { + "MAE": [False], + "RMSE": [False], + "NSE": [False], + "alpha": [False], + "beta": [False], + "HFV": [False], + "HFV10": [False], + "LFV": [False], + "FDC": [False], + "r2": -1, + } + return { + "error_metrics": error_metrics, + "model": None, + "simulated": False, + "response": response, + "forcing": forcing, + "index": index, + "diverged": False, + } + # r2 is how well we're doing across all the outputs. that's actually good to keep model accuracy lumped because that's what makes most sense to drive the optimization + # even though the metrics we'll want to evaluate models on are individual output accuracy + # print("training R^2", r2) + # model.print(precision=5) + + # return false for things not evaluated / don't exist + error_metrics = { + "MAE": [False], + "RMSE": [False], + "NSE": [False], + "alpha": [False], + "beta": [False], + "HFV": [False], + "HFV10": [False], + "LFV": [False], + "FDC": [False], + "r2": r2, + } + simulated = False + if final_run: # only simulate final runs because it's slow + try: # once in high volume training put this back in, but want to see the errors during development + if transform_dependent: + simulated = model.simulate( + response.values[windup_timesteps, :], + t=np.arange(0, len(index), 1)[windup_timesteps:], + u=total_train.values[windup_timesteps:, :], + ) + else: + simulated = model.simulate( + response.values[windup_timesteps, :], + t=np.arange(0, len(index), 1)[windup_timesteps:], + u=forcing.values[windup_timesteps:, :], + ) + mae = list() + rmse = list() + nse = list() + alpha = list() + beta = list() + hfv = list() + hfv10 = list() + lfv = list() + fdc = list() + for col_idx in range( + 0, len(response.columns) + ): # univariate performance metrics + basic = compute_basic_metrics( + response.values[windup_timesteps + 1 :, col_idx], + simulated[:, col_idx], + ) + mae.append(basic["mae"]) + rmse.append(basic["rmse"]) + nse.append(basic["nse"]) + alpha.append(basic["alpha"]) + beta.append(basic["beta"]) + + hfv.append( + 100 + * np.sum( + np.sort(simulated[:, col_idx])[-int(0.02 * len(index)) :] + - np.sort(response.values[windup_timesteps + 1 :, col_idx])[ + -int(0.02 * len(index)) : + ] + ) + / np.sum( + np.sort(response.values[windup_timesteps + 1 :, col_idx])[ + -int(0.02 * len(index)) : + ] + ) + ) + hfv10.append( + 100 + * np.sum( + np.sort(simulated[:, col_idx])[-int(0.1 * len(index)) :] + - np.sort(response.values[windup_timesteps + 1 :, col_idx])[ + -int(0.1 * len(index)) : + ] + ) + / np.sum( + np.sort(response.values[windup_timesteps + 1 :, col_idx])[ + -int(0.1 * len(index)) : + ] + ) + ) + lfv.append( + 100 + * np.sum( + np.sort(simulated[:, col_idx])[-int(0.3 * len(index)) :] + - np.sort(response.values[windup_timesteps + 1 :, col_idx])[ + -int(0.3 * len(index)) : + ] + ) + / np.sum( + np.sort(response.values[windup_timesteps + 1 :, col_idx])[ + -int(0.3 * len(index)) : + ] + ) + ) + fdc.append( + 100 + * ( + np.log10( + np.sort(simulated[:, col_idx])[int(0.2 * len(simulated))] + ) + - np.log10( + np.sort(simulated[:, col_idx])[int(0.7 * len(simulated))] + ) + - np.log10( + np.sort(response.values[windup_timesteps + 1 :, col_idx])[ + int(0.2 * len(simulated)) + ] + ) + + np.log10( + np.sort(response.values[windup_timesteps + 1 :, col_idx])[ + int(0.7 * len(simulated)) + ] + ) + ) + / np.log10( + np.sort(response.values[windup_timesteps + 1 :, col_idx])[ + int(0.2 * len(simulated)) + ] + ) + - np.log10( + np.sort(response.values[windup_timesteps + 1 :, col_idx])[ + int(0.7 * len(simulated)) + ] + ) + ) + + print("MAE = ", mae) + print("RMSE = ", rmse) + print("NSE = ", nse) + # alpha nse decomposition due to gupta et al 2009 + print("alpha = ", alpha) + print("beta = ", beta) + # top 2% peak flow bias (HFV) due to yilmaz et al 2008 + print("HFV = ", hfv) + # top 10% peak flow bias (HFV) due to yilmaz et al 2008 + print("HFV10 = ", hfv10) + # 30% low flow bias (LFV) due to yilmaz et al 2008 + print("LFV = ", lfv) + # bias of FDC midsegment slope due to yilmaz et al 2008 + print("FDC = ", fdc) + # compile all the error metrics into a dictionary + error_metrics = { + "MAE": mae, + "RMSE": rmse, + "NSE": nse, + "alpha": alpha, + "beta": beta, + "HFV": hfv, + "HFV10": hfv10, + "LFV": lfv, + "FDC": fdc, + "r2": r2, + } + + except Exception as e: # and print the exception: + print("Exception in simulation\n") + print(e) + error_metrics = { + "MAE": [np.nan], + "RMSE": [np.nan], + "NSE": [np.nan], + "alpha": [np.nan], + "beta": [np.nan], + "HFV": [np.nan], + "HFV10": [np.nan], + "LFV": [np.nan], + "FDC": [np.nan], + "r2": r2, + } + + return { + "error_metrics": error_metrics, + "model": model, + "simulated": response[1:], + "response": response, + "forcing": forcing, + "index": index, + "diverged": True, + } + + return { + "error_metrics": error_metrics, + "model": model, + "simulated": simulated, + "response": response, + "forcing": forcing, + "index": index, + "diverged": False, + } + # return [r2, model, mae, rmse, index, simulated , response , forcing] diff --git a/modpods/predict.py b/modpods/predict.py new file mode 100644 index 0000000..6f20fbe --- /dev/null +++ b/modpods/predict.py @@ -0,0 +1,221 @@ +import numpy as np + +from .metrics import compute_basic_metrics +from .transforms import transform_inputs + + +def delay_io_predict( + delay_io_model, + system_data, + num_transforms=1, + evaluation=False, + windup_timesteps=None, +): + if ( + windup_timesteps is None + ): # user didn't specify windup timesteps, use what the model trained with. + windup_timesteps = delay_io_model[num_transforms]["windup_timesteps"] + forcing = system_data[delay_io_model[num_transforms]["independent_columns"]].copy( + deep=True + ) + response = system_data[delay_io_model[num_transforms]["dependent_columns"]].copy( + deep=True + ) + + # Use cache from model if available, otherwise create a new one + transform_cache = delay_io_model[num_transforms].get("transform_cache", None) + transformed_forcing = transform_inputs( + shape_factors=delay_io_model[num_transforms]["shape_factors"], + scale_factors=delay_io_model[num_transforms]["scale_factors"], + loc_factors=delay_io_model[num_transforms]["loc_factors"], + index=system_data.index, + forcing=forcing, + cache=transform_cache, + ) + try: + prediction = delay_io_model[num_transforms]["final_model"]["model"].simulate( + system_data[delay_io_model[num_transforms]["dependent_columns"]].iloc[ + windup_timesteps, : + ], + t=np.arange(0, len(system_data.index), 1)[windup_timesteps:], + u=transformed_forcing[windup_timesteps:], + ) + except Exception as e: # and print the exception: + print("Exception in simulation\n") + print(e) + print("diverged.") + error_metrics = { + "MAE": [np.nan], + "RMSE": [np.nan], + "NSE": [np.nan], + "alpha": [np.nan], + "beta": [np.nan], + "HFV": [np.nan], + "HFV10": [np.nan], + "LFV": [np.nan], + "FDC": [np.nan], + } + return { + "prediction": np.nan + * np.ones(shape=response[windup_timesteps + 1 :].shape), + "error_metrics": error_metrics, + "diverged": True, + } + + # return all the error metrics if the prediction is being evaluated against known measurements + if evaluation: + try: + mae = list() + rmse = list() + nse = list() + alpha = list() + beta = list() + hfv = list() + hfv10 = list() + lfv = list() + fdc = list() + for col_idx in range( + 0, len(response.columns) + ): # univariate performance metrics + error = ( + response.values[windup_timesteps + 1 :, col_idx] + - prediction[:, col_idx] + ) + + initial_error_length = len(error) + error = error[~np.isnan(error)] + if len(error) < 0.75 * initial_error_length: + print("WARNING: More than 25% of the entries in error were NaN") + + basic = compute_basic_metrics( + response.values[windup_timesteps + 1 :, col_idx], + prediction[:, col_idx], + ) + mae.append(basic["mae"]) + rmse.append(basic["rmse"]) + nse.append(basic["nse"]) + alpha.append(basic["alpha"]) + beta.append(basic["beta"]) + + hfv.append( + np.sum( + np.sort(prediction[:, col_idx])[ + -int(0.02 * len(system_data.index)) : + ] + ) + / np.sum( + np.sort(response.values[windup_timesteps + 1 :, col_idx])[ + -int(0.02 * len(system_data.index)) : + ] + ) + ) + hfv10.append( + np.sum( + np.sort(prediction[:, col_idx])[ + -int(0.1 * len(system_data.index)) : + ] + ) + / np.sum( + np.sort(response.values[windup_timesteps + 1 :, col_idx])[ + -int(0.1 * len(system_data.index)) : + ] + ) + ) + lfv.append( + np.sum( + np.sort(prediction[:, col_idx])[ + : int(0.3 * len(system_data.index)) + ] + ) + / np.sum( + np.sort(response.values[windup_timesteps + 1 :, col_idx])[ + : int(0.3 * len(system_data.index)) + ] + ) + ) + fdc.append( + np.mean( + np.sort(prediction[:, col_idx])[ + -int(0.6 * len(system_data.index)) : -int( + 0.4 * len(system_data.index) + ) + ] + ) + / np.mean( + np.sort(response.values[windup_timesteps + 1 :, col_idx])[ + -int(0.6 * len(system_data.index)) : -int( + 0.4 * len(system_data.index) + ) + ] + ) + ) + + print("MAE = ", mae) + print("RMSE = ", rmse) + + print("NSE = ", nse) + # alpha nse decomposition due to gupta et al 2009 + print("alpha = ", alpha) + print("beta = ", beta) + # top 2% peak flow bias (HFV) due to yilmaz et al 2008 + print("HFV = ", hfv) + # top 10% peak flow bias (HFV) due to yilmaz et al 2008 + print("HFV10 = ", hfv10) + # 30% low flow bias (LFV) due to yilmaz et al 2008 + print("LFV = ", lfv) + # bias of FDC midsegment slope due to yilmaz et al 2008 + print("FDC = ", fdc) + # compile all the error metrics into a dictionary + error_metrics = { + "MAE": mae, + "RMSE": rmse, + "NSE": nse, + "alpha": alpha, + "beta": beta, + "HFV": hfv, + "HFV10": hfv10, + "LFV": lfv, + "FDC": fdc, + } + # omit r2 here because it doesn't mean the same thing as it does for training, would be misleading. + # r2 in training expresses how much of the derivative is predicted by the model, whereas in evaluation it expresses how much of the response is predicted by the model + + return { + "prediction": prediction, + "error_metrics": error_metrics, + "diverged": False, + } + except Exception as e: # and print the exception: + print(e) + print("Simulation diverged.") + error_metrics = { + "MAE": [np.nan], + "RMSE": [np.nan], + "NSE": [np.nan], + "alpha": [np.nan], + "beta": [np.nan], + "HFV": [np.nan], + "HFV10": [np.nan], + "LFV": [np.nan], + "FDC": [np.nan], + "diverged": [True], + } + + return {"prediction": prediction, "error_metrics": error_metrics} + else: + error_metrics = { + "MAE": [np.nan], + "RMSE": [np.nan], + "NSE": [np.nan], + "alpha": [np.nan], + "beta": [np.nan], + "HFV": [np.nan], + "HFV10": [np.nan], + "LFV": [np.nan], + "FDC": [np.nan], + } + return { + "prediction": prediction, + "error_metrics": error_metrics, + "diverged": False, + } diff --git a/modpods/topology.py b/modpods/topology.py new file mode 100644 index 0000000..d035916 --- /dev/null +++ b/modpods/topology.py @@ -0,0 +1,796 @@ +import warnings + +import networkx as nx +import numpy as np +import pandas as pd +import pysindy as ps +from scipy.optimize import minimize + +from .transforms import transform_inputs + + +def find_topology_no_geo( + system_data, + dependent_columns, + independent_columns, + max_iterations=250, + graph_type="Weak-Conn", + verbose=False, + sensor_locations=None, + init_neighbors=3, +): + """ + Infer network topology from time series data using SINDy-based optimization. + + Args: + system_data: pd.DataFrame with time series data, columns are variables + dependent_columns: list of column names that are dependent variables + independent_columns: list of column names that are independent/forcing variables + max_iterations: maximum iterations for optimization + graph_type: type of graph connectivity requirement ('Weak-Conn') + verbose: whether to print detailed output + sensor_locations: optional dict mapping column names to {"lat": float, "lon": float}. + If provided, uses geographic filtering to reduce computation by only evaluating + nearby sensors as potential forcings. Format: {"station_A": {"lat": 41.5, "lon": -74.5}, ...} + init_neighbors: initial number of nearest neighbors to evaluate when sensor_locations + is provided (default: 3). Ignored if sensor_locations is None. + + Returns: + dict with keys: "edges", "best_params", "r2_values", "lead_lag" + """ + + # only print 3 places past the decimal for floats. don't use scientific notation. if less than 0.001, print as <0.001 + pd.options.display.float_format = "{:.3f}".format + + # Helper function to find the lag with strongest cross-correlation + def cross_correlation_lag(x, y, max_lag): + """Find the lag with strongest cross-correlation between x and y. + + Returns: + best_lag: Positive lag means x leads y (x happens before y) + Negative lag means y leads x (y happens before x) + best_corr: The correlation coefficient at best_lag + """ + best_lag, best_corr = 0, -2 + for lag in range(-max_lag, max_lag + 1): + if lag < 0: + xs = x.iloc[-lag:] + ys = y.iloc[: len(xs)] + elif lag > 0: + ys = y.iloc[lag:] + xs = x.iloc[: len(ys)] + else: + xs, ys = x, y + if len(xs) < 5 or xs.std() == 0 or ys.std() == 0: + continue + c = np.corrcoef(xs, ys)[0, 1] + if np.isnan(c): + continue + if c > best_corr: + best_corr, best_lag = c, lag + return best_lag, best_corr + + # drop columns from system_data which aren't in dependent_columns or independent_columns + # this ensures we only analyze the variables of interest + system_data = pd.concat( + (system_data[independent_columns], system_data[dependent_columns]), + axis="columns", + ) + + # Store results for each column pair + best_params = pd.DataFrame( + index=dependent_columns, columns=system_data.columns, dtype=object + ) + r2_values = pd.DataFrame( + index=dependent_columns, columns=system_data.columns, dtype=float + ) + lead_lag = pd.DataFrame( + index=dependent_columns, columns=system_data.columns, dtype=float + ) + edges = pd.DataFrame( + index=system_data.columns, columns=system_data.columns, dtype=int, data=0 + ) # from column, to row. causation, not flow. + + for dep_col in dependent_columns: + _ = np.array(system_data[dep_col].values) + + # First, compute autocorrelation-only R² (no external forcing) + # This tells us how much of the dynamics can be explained by the state alone + model = ps.SINDy( + differentiation_method=ps.FiniteDifference(order=10, drop_endpoints=True), + feature_library=ps.PolynomialLibrary( + degree=2, include_bias=False, include_interaction=False + ), + optimizer=ps.optimizers.STLSQ(threshold=0, alpha=0), + ) + # Fit with no control input (u=None), just the state + fit = model.fit( + x=system_data.loc[:, dep_col], + t=np.arange(0, len(system_data.index), 1), + feature_names=[dep_col], + ) + auto_r2 = fit.score( + x=system_data.loc[:, dep_col], t=np.arange(0, len(system_data.index), 1) + ) + r2_values.loc[dep_col, dep_col] = auto_r2 + + for forcing_col in system_data.columns: + if forcing_col == dep_col: + continue # already computed autocorrelation above + + # EXPERIMENTAL: Check lead/lag before expensive SISO optimization + # Skip if forcing doesn't lead response (comment out to disable this check) + max_lag_check = min(len(system_data) // 4, 100) + early_lag, early_xcorr = cross_correlation_lag( + system_data[forcing_col], system_data[dep_col], max_lag_check + ) + if early_lag < -5: + print( + f"\nSkipping {forcing_col} -> {dep_col}: forcing lags response (lag={early_lag})" + ) + lead_lag.loc[dep_col, forcing_col] = early_lag + r2_values.loc[dep_col, forcing_col] = 0.0 + best_params.loc[dep_col, forcing_col] = ( + 2.0, + 2.0, + 0.0, + ) # default params + continue + # END EXPERIMENTAL + + print(f"\nOptimizing transformation for {forcing_col} -> {dep_col}") + forcing_orig = system_data[[forcing_col]].copy(deep=True) + + # Objective function to minimize (negative because we want to maximize correlation - p_value) + def objective(params): + shape, scale, loc = params + + # Create transformation parameter DataFrames + shape_factors = pd.DataFrame(columns=[forcing_col], index=[1]) + shape_factors.loc[1, forcing_col] = shape + scale_factors = pd.DataFrame(columns=[forcing_col], index=[1]) + scale_factors.loc[1, forcing_col] = scale + loc_factors = pd.DataFrame(columns=[forcing_col], index=[1]) + loc_factors.loc[1, forcing_col] = loc + + try: + transformed_inputs = pd.DataFrame(index=system_data.index) + # SINDY way + transformed = transform_inputs( + shape_factors, + scale_factors, + loc_factors, + system_data.index, + forcing_orig, + ) + transformed_inputs = pd.concat( + (transformed_inputs, transformed[[forcing_col + "_tr_1"]]), + axis="columns", + ) + # build a sindy model with these inputs + feature_names = [dep_col, str(forcing_col + "_tr_1")] + model = ps.SINDy( + differentiation_method=ps.FiniteDifference( + order=10, drop_endpoints=True + ), + feature_library=ps.PolynomialLibrary( + degree=2, include_bias=False, include_interaction=False + ), + optimizer=ps.optimizers.STLSQ(threshold=0, alpha=0), + ) + fit = model.fit( + x=system_data.loc[:, dep_col], + u=transformed_inputs, + t=np.arange(0, len(system_data.index), 1), + feature_names=feature_names, + ) + r2 = fit.score( + x=system_data.loc[:, dep_col], + u=transformed_inputs, + t=np.arange(0, len(system_data.index), 1), + ) + + return -r2 # Negative because minimize + except Exception as e: + # if e contains any letters or numbers, print it for debugging + if any(c.isalnum() for c in str(e)): + if verbose: + print(f"Exception in objective function: {e}") + + return 1e10 # Large penalty for invalid parameters + + # Initial guess and bounds + x0 = [2.0, 2.0, 0.0] + bounds = [(1.0, 300.0), (1e-5, 300.0), (0, 300.0)] # shape, scale, loc + + # Optimize + result = minimize( + objective, + x0, + method="Nelder-Mead", + bounds=bounds, + options={"maxiter": max_iterations, "disp": verbose, "fatol": 1e-4}, + ) + + # Store best results + best_shape, best_scale, best_loc = result.x + + # Compute final correlation and p_value with best parameters + shape_factors = pd.DataFrame(columns=[forcing_col], index=[1]) + shape_factors.loc[1, forcing_col] = best_shape + scale_factors = pd.DataFrame(columns=[forcing_col], index=[1]) + scale_factors.loc[1, forcing_col] = best_scale + loc_factors = pd.DataFrame(columns=[forcing_col], index=[1]) + loc_factors.loc[1, forcing_col] = best_loc + + transformed = transform_inputs( + shape_factors, + scale_factors, + loc_factors, + system_data.index, + forcing_orig, + ) + _ = np.array(transformed[forcing_col + "_tr_1"].values) + feature_names = [dep_col, forcing_col] + model = ps.SINDy( + differentiation_method=ps.FiniteDifference( + order=10, drop_endpoints=True + ), + feature_library=ps.PolynomialLibrary( + degree=2, include_bias=False, include_interaction=False + ), + optimizer=ps.optimizers.STLSQ(threshold=0, alpha=0), + ) + fit = model.fit( + x=system_data.loc[:, dep_col], + t=np.arange(0, len(system_data.index), 1), + u=transformed, + feature_names=feature_names, + ) + # evaluate the r2 score + r2 = fit.score( + x=system_data.loc[:, dep_col], + t=np.arange(0, len(system_data.index), 1), + u=transformed, + ) + try: + model.print() + except Exception as e: + print(e) + + r2_values.loc[dep_col, forcing_col] = r2 + + # Compute cross-correlation lag between forcing and response + # Use max_lag of 1/4 of the data length, capped at 100 + max_lag = min(len(system_data) // 4, 100) + best_lag, best_xcorr = cross_correlation_lag( + system_data[forcing_col], system_data[dep_col], max_lag + ) + lead_lag.loc[dep_col, forcing_col] = best_lag + + print(f"\nOptimizing transformation for {forcing_col} -> {dep_col}") + print( + f" BEST: shape={best_shape:.2f}, scale={best_scale:.2f}, loc={best_loc:.2f}" + ) + print(f" Cross-correlation: lag={best_lag}, corr={best_xcorr:.4f}") + # save the best parameters + best_params.loc[dep_col, forcing_col] = (best_shape, best_scale, best_loc) + + print("R2 Values:") + print(r2_values) + print("\n") + + print("Final SISO R2 Values:") + print(r2_values) + current_best_r2 = pd.Series(index=dependent_columns, dtype=float, data=0.0) + print("Lead/Lag Matrix: (positive lag means forcing leads response)") + print(lead_lag) + + # OPTION A: Mask r2 values by nonnegative lead/lag (forcing must lead response) + # This is applied AFTER SISO optimization - use this if not skipping early + # r2_values = r2_values.mask(lead_lag < 0, 0) + # print("Masked R2 Values (only forcing leads response):") + # print(r2_values) + + # OPTION B: Early skip is done above in the SISO loop - r2_values already has 0s for skipped pairs + + # first identify the maximum r^2 value in each row. we know these will be included in the final topology + # with an exception: if we form a cycle with these initial edges, remove the lowest r^2 edge in the cycle + # for dep_col in dependent_columns: + # forcing_col = r2_values.loc[dep_col,:].idxmax() + # edges.loc[dep_col,forcing_col] = 1 + # current_best_r2[dep_col] = r2_values.loc[dep_col,forcing_col] + + # try a different method of picking initial edges + # find the n_columns edges in r2_values with the highest r^2 values + # if they are the maximum in their row and column, include them + sorted_r2 = r2_values.stack().sort_values(ascending=False) + for idx in sorted_r2.index: + dep_col = idx[0] + forcing_col = idx[1] + r2 = r2_values.loc[dep_col, forcing_col] + # is this the maximum in its row and column? (strongest connection for giver and receiver) + if ( + r2 == r2_values.loc[dep_col, :].max() + and r2 == r2_values.loc[:, forcing_col].max() + ): + edges.loc[dep_col, forcing_col] = 1 + current_best_r2[dep_col] = r2_values.loc[dep_col, forcing_col] + print(f"Initial edge added: {forcing_col} -> {dep_col} with r^2 = {r2:.4f}") + + # check for cycles and remove them iteratively + G = nx.from_pandas_adjacency(edges, create_using=nx.DiGraph) + while True: + try: + # find_cycle returns a list of edges forming ONE cycle: [(u, v, dir), (v, w, dir), ...] + cycle_edges = list(nx.find_cycle(G, orientation="original")) + if len(cycle_edges) == 0: + break + + print( + f"Found cycle with {len(cycle_edges)} edges. Removing lowest r^2 edge." + ) + print(f"Cycle edges: {[(e[0], e[1]) for e in cycle_edges]}") + + # find the edge with the lowest r^2 in the cycle + min_r2 = float("inf") + edge_to_remove = None + for edge in cycle_edges: + from_node = edge[0] # source node + to_node = edge[1] # target node + # In our adjacency matrix, edges.loc[row, col] = 1 means col -> row + # So we need r2_values.loc[to_node, from_node] for edge from_node -> to_node + r2 = r2_values.loc[to_node, from_node] + print(f" Edge {from_node} -> {to_node}: r^2 = {r2:.4f}") + if r2 < min_r2: + min_r2 = r2 + edge_to_remove = (from_node, to_node) + + # remove this edge from our edges DataFrame + # edges.loc[row, col] = 1 means col -> row, so to remove from_node -> to_node: + edges.loc[edge_to_remove[1], edge_to_remove[0]] = 0 + print( + f" Removed edge {edge_to_remove[0]} -> {edge_to_remove[1]} with r^2 = {min_r2:.4f}" + ) + + # rebuild the graph for next iteration + G = nx.from_pandas_adjacency(edges, create_using=nx.DiGraph) + + except nx.NetworkXNoCycle: + # No cycle found, we're done + print("No cycles detected in initial edges.") + break + except Exception as e: + print(f"Error during cycle detection: {e}") + break + + # Helper function to update correlation-weighted R² scores for a single output variable + def update_corr_weighted_r2(dep_col): + """Update corr_wted_r2 for all potential inputs to dep_col based on current edges.""" + selected_inputs = list(edges.loc[dep_col, edges.loc[dep_col, :] == 1].index) + for forcing_col in system_data.columns: + if forcing_col in selected_inputs or forcing_col == dep_col: + continue # skip already selected inputs / autocorrelation + + if len(selected_inputs) > 0: + correlations = [] + for sel_input in selected_inputs: + # compute correlation between transformed versions of forcing_col and sel_input + shape_factors_1 = pd.DataFrame(columns=[forcing_col], index=[1]) + shape_factors_1.loc[1, forcing_col] = best_params.loc[ + dep_col, forcing_col + ][0] + scale_factors_1 = pd.DataFrame(columns=[forcing_col], index=[1]) + scale_factors_1.loc[1, forcing_col] = best_params.loc[ + dep_col, forcing_col + ][1] + loc_factors_1 = pd.DataFrame(columns=[forcing_col], index=[1]) + loc_factors_1.loc[1, forcing_col] = best_params.loc[ + dep_col, forcing_col + ][2] + transformed_1 = transform_inputs( + shape_factors_1, + scale_factors_1, + loc_factors_1, + system_data.index, + system_data[[forcing_col]], + ) + + shape_factors_2 = pd.DataFrame(columns=[sel_input], index=[1]) + shape_factors_2.loc[1, sel_input] = best_params.loc[ + dep_col, sel_input + ][0] + scale_factors_2 = pd.DataFrame(columns=[sel_input], index=[1]) + scale_factors_2.loc[1, sel_input] = best_params.loc[ + dep_col, sel_input + ][1] + loc_factors_2 = pd.DataFrame(columns=[sel_input], index=[1]) + loc_factors_2.loc[1, sel_input] = best_params.loc[ + dep_col, sel_input + ][2] + transformed_2 = transform_inputs( + shape_factors_2, + scale_factors_2, + loc_factors_2, + system_data.index, + system_data[[sel_input]], + ) + + together = pd.DataFrame(index=system_data.index) + together[forcing_col] = transformed_1[str(forcing_col + "_tr_1")] + together[sel_input] = transformed_2[str(sel_input + "_tr_1")] + + # Check for zero variance before computing correlation + if ( + together[forcing_col].std() == 0 + or together[sel_input].std() == 0 + ): + corr = 2.0 # constant variable, exclude it + else: + corr = np.corrcoef(together[forcing_col], together[sel_input])[ + 0, 1 + ] + if np.isnan(corr): + corr = 0.0 + correlations.append(abs(corr)) + _ = np.max(correlations) + else: + _ = 0.0 + + corr_wted_r2.loc[dep_col, forcing_col] = ( + r2_values.loc[dep_col, forcing_col] * 1 + ) # ((1 - max_corr)) # was **10 + + # Initialize correlation-weighted R² scores + corr_wted_r2 = r2_values.copy(deep=True) + for dep_col in dependent_columns: + update_corr_weighted_r2(dep_col) + + sorted_r2 = r2_values.stack().sort_values(ascending=False) + if verbose: + print("Sorted R2 values:") + print(sorted_r2) + + # Use a while loop so we can re-sort after each edge addition + # This ensures we always pick the best remaining candidate after correlation weights are updated + evaluated_pairs = ( + set() + ) # Track pairs we've already evaluated to avoid infinite loops + + while True: + sorted_corr_wted_r2 = corr_wted_r2.stack().sort_values(ascending=False) + # Find the best candidate we haven't evaluated yet + idx = None + for candidate_idx in sorted_corr_wted_r2.index: + if ( + candidate_idx not in evaluated_pairs + and edges.loc[candidate_idx[0], candidate_idx[1]] != 1 + ): + idx = candidate_idx + break + + if idx is None: + print("No more candidate edges to evaluate.") + break + + evaluated_pairs.add(idx) + output_variable = idx[0] + forcing_variable = idx[1] + r2 = r2_values.loc[output_variable, forcing_variable] + + non_rain_edges = edges.loc[ + ~edges.index.str.contains("rain"), ~edges.columns.str.contains("rain") + ] + + # would adding this edge reduce the number of components in the graph? (not considering rain) + non_rain_edges_if_added = non_rain_edges.copy(deep=True) + non_rain_edges_if_added.loc[output_variable, forcing_variable] = 1 + + n_components_now = nx.number_weakly_connected_components( + nx.from_pandas_adjacency(non_rain_edges, create_using=nx.DiGraph) + ) + if n_components_now == 1: + print("graph is weakly connected.") + # done + break + + n_components = nx.number_weakly_connected_components( + nx.from_pandas_adjacency(non_rain_edges_if_added, create_using=nx.DiGraph) + ) + if "rain" not in forcing_variable.lower(): # always allow rain edges + if n_components >= n_components_now: + print( + f"Skipping addition of {forcing_variable} -> {output_variable} as it does not improve connectivity" + ) + continue # skip this addition as it doesn't improve connectivity + + print( + f"Evaluating edge {forcing_variable} -> {output_variable} with r2 = {r2:.4f}" + ) + print("current best r2 values:") + print(current_best_r2) + # build the candidate input set + selected_inputs = list( + edges.loc[output_variable, edges.loc[output_variable, :] == 1].index + ) + candidate_inputs = selected_inputs + [forcing_variable] + + # optimize the transformations for all candidate inputs together, using siso best params as initial guesses + def joint_objective(params, debug=False): + # params is a flat list of shape, scale, loc for each candidate input + transformed_inputs = pd.DataFrame(index=system_data.index) + for i, input_var in enumerate(candidate_inputs): + shape = params[i * 3] + scale = params[i * 3 + 1] + loc = params[i * 3 + 2] + shape_factors = pd.DataFrame(columns=[input_var], index=[1]) + shape_factors.loc[1, input_var] = shape + scale_factors = pd.DataFrame(columns=[input_var], index=[1]) + scale_factors.loc[1, input_var] = scale + loc_factors = pd.DataFrame(columns=[input_var], index=[1]) + loc_factors.loc[1, input_var] = loc + forcing_orig = system_data[[input_var]].copy() + transformed = transform_inputs( + shape_factors, + scale_factors, + loc_factors, + system_data.index, + forcing_orig, + ) + # Include BOTH original and transformed columns, consistent with SISO phase + transformed_inputs = pd.concat( + (transformed_inputs, transformed), axis="columns" + ) + # build and fit the sindy model + feature_names = [output_variable] + list(transformed_inputs.columns) + model = ps.SINDy( + differentiation_method=ps.FiniteDifference( + order=10, drop_endpoints=True + ), + feature_library=ps.PolynomialLibrary( + degree=2, include_bias=False, include_interaction=False + ), + optimizer=ps.optimizers.STLSQ(threshold=0, alpha=0), + ) + fit = model.fit( + x=system_data.loc[:, output_variable], + t=np.arange(0, len(system_data.index), 1), + u=transformed_inputs, + feature_names=feature_names, + ) + r2 = fit.score( + x=system_data.loc[:, output_variable], + t=np.arange(0, len(system_data.index), 1), + u=transformed_inputs, + ) + if debug: + print( + f" DEBUG joint_objective: inputs={list(transformed_inputs.columns)}, r2={r2:.4f}" + ) + try: + model.print() + except Exception: + pass + return -r2 # Negative because minimize + + # initial guesses from SISO optimization + x0 = [] + for input_var in candidate_inputs: + shape, scale, loc = best_params.loc[output_variable, input_var] + x0.extend([shape, scale, loc]) + bounds = [] + for input_var in candidate_inputs: + bounds.extend( + [(1.0, 300.0), (1e-5, 300.0), (0.0, 300.0)] + ) # shape, scale, loc + + # First, compute baseline R² using SISO-optimized params (x0) + # This ensures we never do worse than the initial guess + baseline_r2 = -joint_objective(x0, debug=True) + print(f" Baseline R² with SISO params: {baseline_r2:.4f}") + + # optimize + multivariable_iterations = max_iterations * len(candidate_inputs) + result = minimize( + joint_objective, + x0, + method="Nelder-Mead", + bounds=bounds, + options={"maxiter": multivariable_iterations, "disp": verbose}, + ) + optimized_r2 = -result.fun + + # Use optimized params only if they improve on baseline, otherwise keep SISO params + if optimized_r2 >= baseline_r2: + optimized_params = result.x + print(f" Optimizer improved R² to {optimized_r2:.4f}") + else: + optimized_params = x0 + print( + f" Optimizer found worse R² ({optimized_r2:.4f}), keeping SISO params (R² = {baseline_r2:.4f})" + ) + + # extract best params + for i, input_var in enumerate(candidate_inputs): + shape = optimized_params[i * 3] + scale = optimized_params[i * 3 + 1] + loc = optimized_params[i * 3 + 2] + best_params.loc[output_variable, input_var] = (shape, scale, loc) + # compute final r2 with optimized params + transformed_inputs = pd.DataFrame(index=system_data.index) + for i, input_var in enumerate(candidate_inputs): + shape = optimized_params[i * 3] + scale = optimized_params[i * 3 + 1] + loc = optimized_params[i * 3 + 2] + shape_factors = pd.DataFrame(columns=[input_var], index=[1]) + shape_factors.loc[1, input_var] = shape + scale_factors = pd.DataFrame(columns=[input_var], index=[1]) + scale_factors.loc[1, input_var] = scale + loc_factors = pd.DataFrame(columns=[input_var], index=[1]) + loc_factors.loc[1, input_var] = loc + forcing_orig = system_data[[input_var]].copy() + transformed = transform_inputs( + shape_factors, + scale_factors, + loc_factors, + system_data.index, + forcing_orig, + ) + # Include BOTH original and transformed columns, consistent with SISO phase + transformed_inputs = pd.concat( + (transformed_inputs, transformed), axis="columns" + ) + feature_names = [output_variable] + list(transformed_inputs.columns) + model = ps.SINDy( + differentiation_method=ps.FiniteDifference(order=10, drop_endpoints=True), + feature_library=ps.PolynomialLibrary( + degree=2, include_bias=False, include_interaction=False + ), + optimizer=ps.optimizers.STLSQ(threshold=0, alpha=0), + ) + fit = model.fit( + x=system_data.loc[:, output_variable], + t=np.arange(0, len(system_data.index), 1), + u=transformed_inputs, + feature_names=feature_names, + ) + r2 = fit.score( + x=system_data.loc[:, output_variable], + t=np.arange(0, len(system_data.index), 1), + u=transformed_inputs, + ) + + print( + f"Testing inputs {candidate_inputs} for output {output_variable} -> r2 = {r2:.4f}" + ) + if ( + r2 > current_best_r2[output_variable] + 0.01 + ): # only keep it if it improves the r2 by at least 1% + # add a conditional here for reducing the number of components in the graph. if it doesn't connect things that were previously unconnected, we don't want it. + selected_inputs = candidate_inputs + current_best_r2[output_variable] = r2 + print( + f" Accepted new input {forcing_variable}, updated r2 = {current_best_r2[output_variable]:.4f}" + ) + edges.loc[output_variable, forcing_variable] = 1 + + # Update correlation-weighted R² for this output since we added a new input + # The while loop will re-sort at the next iteration + update_corr_weighted_r2(output_variable) + + else: + print(f" Rejected new input {forcing_variable}, r2 would be {r2:.4f}") + + # transpose edges to have from -> to convention + edges = edges.T + # earlier in the code we have dependent variables on the rows and independent on columns. + # that arrangement makes comparing the effect of potential inputs on each output easier. + # but for output, it's more intuitive to have from -> to convention, so we transpose before returning. + + return { + "edges": edges, + "best_params": best_params, + "r2_values": r2_values, + "lead_lag": lead_lag, + } + + +def infer_causative_topology( # noqa: F811 + # type: ignore + system_data, + dependent_columns, + independent_columns, + graph_type="Weak-Conn", + verbose=False, + max_iter=250, + swmm=False, + method="sindy", # Changed default from "granger" to "sindy" + derivative=False, + sensor_locations=None, + init_neighbors=3, +): + """ + Infer causative topology from time series data using SINDy-based optimization. + + Args: + system_data: pd.DataFrame with time series data + dependent_columns: list of column names that are dependent variables + independent_columns: list of column names that are independent/forcing variables + graph_type: type of graph connectivity requirement ('Weak-Conn' or 'Strong-Conn') + verbose: whether to print detailed output + max_iter: maximum iterations for optimization + swmm: whether this is for SWMM/pystorms data + method: inference method ('sindy' is the only supported method now) + derivative: whether to use derivative of response + sensor_locations: optional dict mapping column names to {"lat": float, "lon": float} + init_neighbors: initial number of nearest neighbors to evaluate when sensor_locations is provided (default: 3) + + Returns: + dict with keys: "edges", "best_params", "r2_values", "lead_lag" + - edges: DataFrame adjacency matrix (from -> to convention) + - best_params: DataFrame of transformation parameters (shape, scale, loc) + - r2_values: DataFrame of R^2 values for each potential edge + - lead_lag: DataFrame of lead/lag values (positive = forcing leads response) + """ + + # Handle deprecated methods + if method in ("granger", "ccm", "transfer_entropy"): + warnings.warn( + f"Method '{method}' is deprecated. The Granger causality, CCM, and " + "Transfer Entropy methods have been replaced by the improved SINDy-based " + "topology inference (method='sindy'), which provides significantly better " + "results. Please use method='sindy' (the new default).", + DeprecationWarning, + stacklevel=2, + ) + # Fall back to new method + method = "sindy" + + if swmm: + # do the same for dependent_columns and independent_columns + dependent_columns = [str(col) for col in dependent_columns] + independent_columns = [str(col) for col in independent_columns] + # do the same for the columns of system_data + system_data.columns = system_data.columns.astype(str) + + # Import and use the new SINDy-based topology inference + # (using our local implementation) + result = find_topology_no_geo( + system_data=system_data, + dependent_columns=dependent_columns, + independent_columns=independent_columns, + sensor_locations=sensor_locations, + max_iterations=max_iter, + graph_type=graph_type, + verbose=verbose, + init_neighbors=init_neighbors, + ) + # Convert result to match expected return format for backward compatibility + # The new method returns edges in from->to convention (transposed from old) + edges = result["edges"] + _ = result["best_params"] + r2_values = result["r2_values"] + _ = result["lead_lag"] + + # For backward compatibility with code expecting (causative_topo, total_graph) tuple + # causative_topo: 'd' for directed edge, 'n' for no edge + # total_graph: numeric weights (R² values) + causative_topo = pd.DataFrame( + index=dependent_columns, columns=system_data.columns + ).fillna("n") + total_graph = pd.DataFrame( + index=dependent_columns, columns=system_data.columns, dtype=float + ).fillna(0.0) + + # Fill in the edges from the result + # edges is in from->to convention (row=from, col=to) + # causative_topo expects row=dependent (to), col=forcing (from) + for dep_col in dependent_columns: + for forcing_col in system_data.columns: + if edges.loc[forcing_col, dep_col] == 1: # from forcing_col -> to dep_col + causative_topo.loc[dep_col, forcing_col] = "d" + total_graph.loc[dep_col, forcing_col] = r2_values.loc[ + dep_col, forcing_col + ] + + return causative_topo, total_graph diff --git a/modpods/train.py b/modpods/train.py new file mode 100644 index 0000000..874315a --- /dev/null +++ b/modpods/train.py @@ -0,0 +1,949 @@ +from typing import Any, cast + +import numpy as np +import pandas as pd +from sklearn.gaussian_process import GaussianProcessRegressor +from sklearn.gaussian_process.kernels import Matern + +from .model import SINDY_delays_MI +from .transforms import _expected_improvement, _propose_location, _transform_cache + + +def _run_scipy_optimizer( + optimization_method: str, + objective_function, + bounds: np.ndarray, + max_iter: int, + verbose: bool, + optimizer_kwargs: dict, +) -> np.ndarray: + """ + Dispatch to scipy.optimize methods for global optimization. + + Supports any scipy.optimize method that accepts (objective, bounds, **kwargs). + Common methods: 'differential_evolution', 'dual_annealing', 'simulated_annealing', + 'basinhopping', 'shgo', 'direct', 'brute'. + + Args: + optimization_method: Name of scipy.optimize method to use + objective_function: Callable that takes parameter vector and returns scalar to minimize + bounds: Array of [min, max] bounds for each parameter + max_iter: Maximum iterations (used as default for methods that support it) + verbose: Whether to print progress + optimizer_kwargs: Additional keyword arguments passed to the optimizer + + Returns: + Best parameter vector found + """ + import scipy.optimize as opt + + # Default parameters for each method + method_defaults = { + "differential_evolution": { + "maxiter": max_iter, + "popsize": 15, + "mutation": (0.5, 1.5), + "recombination": 0.7, + "seed": 42, + "updating": "deferred", + }, + "dual_annealing": { + "maxiter": max_iter * 4, # DA needs more iterations for good exploration + "seed": 42, + "no_local_search": False, + }, + "simulated_annealing": { + "maxiter": max_iter * 4, + "seed": 42, + }, + "direct": { + "maxiter": max_iter, + "eps": 1e-4, + }, + "brute": { + "Ns": 20, + }, + } + + # Get defaults for this method, or empty dict if unknown + defaults = cast(dict[str, Any], method_defaults.get(optimization_method, {})) + + # Merge defaults with user-provided kwargs (user kwargs take precedence) + params = {**defaults, **optimizer_kwargs} + + # Get the optimizer function + optimizer = getattr(opt, optimization_method, None) + if optimizer is None: + raise ValueError( + f"Unknown optimization_method: '{optimization_method}'. " + f"Supported scipy.optimize methods: {list(method_defaults.keys())}, " + f"or 'bayesian' for built-in Bayesian optimization." + ) + + if verbose: + print(f" Running scipy.optimize.{optimization_method} with params: {params}") + + # Run the optimizer + result = optimizer(objective_function, bounds, **params) + + if verbose: + print( + f" Optimization complete. Success: {result.success}, Message: {result.message}" + ) + print(f" Best value: {-result.fun:.6f} (R²)") + + return result.x # type: ignore[no-any-return] + + +def delay_io_train( + system_data, + dependent_columns, + independent_columns, + windup_timesteps=0, + init_transforms=1, + max_transforms=4, + max_iter=250, + poly_order=3, + transform_dependent=False, + verbose=False, + extra_verbose=False, + include_bias=False, + include_interaction=False, + bibo_stable=False, + transform_only=None, + forcing_coef_constraints=None, + early_stopping_threshold=0.005, + optimization_method="bayesian", + **optimizer_kwargs, +): + forcing = system_data[independent_columns].copy(deep=True) + + orig_forcing_columns = forcing.columns + response = system_data[dependent_columns].copy(deep=True) + + results = dict() # to store the optimized models for each number of transformations + prev_model = ( + None # will hold the initial model for the current number of transforms + ) + + if transform_dependent: + shape_factors = pd.DataFrame( + columns=system_data.columns, + index=range(init_transforms, max_transforms + 1), + ) + shape_factors.iloc[0, :] = 1 # first transformation is [1,1,0] for each input + scale_factors = pd.DataFrame( + columns=system_data.columns, + index=range(init_transforms, max_transforms + 1), + ) + scale_factors.iloc[0, :] = 1 # first transformation is [1,1,0] for each input + loc_factors = pd.DataFrame( + columns=system_data.columns, + index=range(init_transforms, max_transforms + 1), + ) + loc_factors.iloc[0, :] = 0 # first transformation is [1,1,0] for each input + elif transform_only is not None: # the user provided a list of columns to transform + shape_factors = pd.DataFrame( + columns=transform_only, index=range(init_transforms, max_transforms + 1) + ) + shape_factors.iloc[0, :] = 1 # first transformation is [1,1,0] for each input + scale_factors = pd.DataFrame( + columns=transform_only, index=range(init_transforms, max_transforms + 1) + ) + scale_factors.iloc[0, :] = 1 # first transformation is [1,1,0] for each input + loc_factors = pd.DataFrame( + columns=transform_only, index=range(init_transforms, max_transforms + 1) + ) + loc_factors.iloc[0, :] = 0 # first transformation is [1,1,0] for each input + else: + # the transformation factors should be pandas dataframes where the index is which transformation it is and the columns are the variables + shape_factors = pd.DataFrame( + columns=forcing.columns, index=range(init_transforms, max_transforms + 1) + ) + shape_factors.iloc[0, :] = 1 # first transformation is [1,1,0] for each input + scale_factors = pd.DataFrame( + columns=forcing.columns, index=range(init_transforms, max_transforms + 1) + ) + scale_factors.iloc[0, :] = 1 # first transformation is [1,1,0] for each input + loc_factors = pd.DataFrame( + columns=forcing.columns, index=range(init_transforms, max_transforms + 1) + ) + loc_factors.iloc[0, :] = 0 # first transformation is [1,1,0] for each input + # first transformation is [1,1,0] for each input + speeds = list( + [100, 50, 20, 10, 5, 2, 1.1, 1.05, 1.01] + ) # I don't have a great idea of what good values for these are yet + if transform_dependent: # just trying something + improvement_threshold = ( + 1.001 # when improvements are tiny, tighten up the jumps + ) + else: + improvement_threshold = 1.0 + + for num_transforms in range(init_transforms, max_transforms + 1): + print("num_transforms") + print(num_transforms) + speed_idx = 0 + speed = speeds[speed_idx] + + if not num_transforms == init_transforms: # if we're not starting right now + # start dull + shape_factors.iloc[num_transforms - 1, :] = 10 * ( + num_transforms - 1 + ) # start with a broad peak centered at ten timesteps + scale_factors.iloc[num_transforms - 1, :] = 1 + loc_factors.iloc[num_transforms - 1, :] = 0 + if verbose: + print( + "starting factors for additional transformation\nshape\nscale\nlocation" + ) + print(shape_factors) + print(scale_factors) + print(loc_factors) + + # Choose optimization method + if optimization_method == "bayesian": + if verbose: + print(f"Using Bayesian optimization for {num_transforms} transforms...") + + # Determine which columns to transform + if transform_dependent: + transform_columns = system_data.columns.tolist() + elif transform_only is not None: + transform_columns = transform_only + else: + transform_columns = independent_columns + + # Bayesian optimization for this number of transforms + bounds_list: list[list[float]] = [] + for transform in range(1, num_transforms + 1): + for col in transform_columns: + bounds_list.append([1.0, 50.0]) # shape_factors bounds + bounds_list.append([0.1, 5.0]) # scale_factors bounds + bounds_list.append([0.0, 20.0]) # loc_factors bounds + bounds = np.array(bounds_list) + + def objective_function(params_vector): + try: + # Convert vector to DataFrames + shape_factors_opt = pd.DataFrame( + columns=transform_columns, index=range(1, num_transforms + 1) + ) + scale_factors_opt = pd.DataFrame( + columns=transform_columns, index=range(1, num_transforms + 1) + ) + loc_factors_opt = pd.DataFrame( + columns=transform_columns, index=range(1, num_transforms + 1) + ) + + idx = 0 + for transform in range(1, num_transforms + 1): + for col in transform_columns: + shape_factors_opt.loc[transform, col] = params_vector[idx] + scale_factors_opt.loc[transform, col] = params_vector[ + idx + 1 + ] + loc_factors_opt.loc[transform, col] = params_vector[idx + 2] + idx += 3 + + result = SINDY_delays_MI( + shape_factors_opt, + scale_factors_opt, + loc_factors_opt, + system_data.index, + forcing, + response, + False, + poly_order, + include_bias, + include_interaction, + windup_timesteps, + bibo_stable, + transform_dependent, + transform_only, + forcing_coef_constraints, + transform_cache=_transform_cache, + ) + + r2 = result["error_metrics"]["r2"] + if verbose: + print(f" R² = {r2:.6f}") + return r2 + except Exception as e: + if verbose: + print(f" Evaluation failed: {e}") + return -1.0 + + # Bayesian optimization + # Use more iterations for Bayesian optimization to build a good surrogate model + # Cap at 200 to avoid memory issues with GP fitting + bayesian_max_iter = min(max_iter * 4, 200) + n_initial = min(20, max(10, bayesian_max_iter // 4)) + X_sample_list: list[Any] = [] + Y_sample_list: list[Any] = [] + + # Generate initial random samples + for i in range(n_initial): + x = np.random.uniform(bounds[:, 0], bounds[:, 1]) + y = objective_function(x) + X_sample_list.append(x) + Y_sample_list.append(y) + if verbose: + print(f" Initial sample {i+1}/{n_initial}: R² = {y:.6f}") + + X_sample: np.ndarray = np.array(X_sample_list) + Y_sample: np.ndarray = np.array(Y_sample_list).reshape(-1, 1) + + # Main Bayesian optimization loop + best_r2 = np.max(Y_sample) + best_params: np.ndarray = X_sample[np.argmax(Y_sample)] + + # Gaussian Process setup + kernel = Matern(length_scale=1.0, nu=2.5) + gpr = GaussianProcessRegressor( + kernel=kernel, + alpha=1e-6, + normalize_y=True, + n_restarts_optimizer=5, + random_state=42, + ) + + for iteration in range(bayesian_max_iter - n_initial): + # Fit GP and find next point + gpr.fit(X_sample, Y_sample.ravel()) + next_x = _propose_location( + _expected_improvement, X_sample, Y_sample, gpr, bounds + ) + next_x = next_x.flatten() + + # Evaluate objective + next_y = objective_function(next_x) + + if verbose: + print( + f" BO iteration {iteration+1}/{bayesian_max_iter-n_initial}: R² = {next_y:.6f}" + ) + + # Update samples + X_sample = np.append(X_sample, [next_x], axis=0) + Y_sample = np.append(Y_sample, next_y) + + # Update best + if next_y > best_r2: + best_r2 = next_y + best_params = next_x + if verbose: + print(f" New best R² = {best_r2:.6f}") + + # Convert best parameters back to DataFrames + idx = 0 + for transform in range(1, num_transforms + 1): + for col in transform_columns: + shape_factors.loc[transform, col] = best_params[idx] + scale_factors.loc[transform, col] = best_params[idx + 1] + loc_factors.loc[transform, col] = best_params[idx + 2] + idx += 3 + + # Use the optimized parameters for final evaluation + prev_model = SINDY_delays_MI( + shape_factors, + scale_factors, + loc_factors, + system_data.index, + forcing, + response, + extra_verbose, + poly_order, + include_bias, + include_interaction, + windup_timesteps, + bibo_stable, + transform_dependent=transform_dependent, + transform_only=transform_only, + forcing_coef_constraints=forcing_coef_constraints, + transform_cache=_transform_cache, + ) + + else: + # Use scipy.optimize for all other methods (differential_evolution, dual_annealing, + # basinhopping, shgo, direct, etc.) + if verbose: + print( + f"Using {optimization_method} optimization for {num_transforms} transforms..." + ) + + # Determine which columns to transform + if transform_dependent: + transform_columns = system_data.columns.tolist() + elif transform_only is not None: + transform_columns = transform_only + else: + transform_columns = independent_columns + + # Define parameter bounds for this number of transforms + bounds_list: list[list[float]] = [] # type: ignore[no-redef] + for transform in range(1, num_transforms + 1): + for col in transform_columns: + bounds_list.append([1.0, 50.0]) # shape_factors bounds + bounds_list.append([0.1, 5.0]) # scale_factors bounds + bounds_list.append([0.0, 20.0]) # loc_factors bounds + bounds = np.array(bounds_list) + + def objective_function(params_vector): + try: + # Convert vector to DataFrames + shape_factors_opt = pd.DataFrame( + columns=transform_columns, index=range(1, num_transforms + 1) + ) + scale_factors_opt = pd.DataFrame( + columns=transform_columns, index=range(1, num_transforms + 1) + ) + loc_factors_opt = pd.DataFrame( + columns=transform_columns, index=range(1, num_transforms + 1) + ) + + idx = 0 + for transform in range(1, num_transforms + 1): + for col in transform_columns: + shape_factors_opt.loc[transform, col] = params_vector[idx] + scale_factors_opt.loc[transform, col] = params_vector[ + idx + 1 + ] + loc_factors_opt.loc[transform, col] = params_vector[idx + 2] + idx += 3 + + result = SINDY_delays_MI( + shape_factors_opt, + scale_factors_opt, + loc_factors_opt, + system_data.index, + forcing, + response, + False, + poly_order, + include_bias, + include_interaction, + windup_timesteps, + bibo_stable, + transform_dependent=transform_dependent, + transform_only=transform_only, + forcing_coef_constraints=forcing_coef_constraints, + transform_cache=_transform_cache, + ) + + r2 = result["error_metrics"]["r2"] + if verbose: + print(f" R² = {r2:.6f}") + return -r2 # Minimize negative R² (maximize R²) + except Exception as e: + if verbose: + print(f" Evaluation failed: {e}") + return 1.0 # Poor score for failed evaluations + + # Dispatch to scipy.optimize method + best_params = _run_scipy_optimizer( + optimization_method=optimization_method, + objective_function=objective_function, + bounds=bounds, + max_iter=max_iter, + verbose=verbose, + optimizer_kwargs=optimizer_kwargs, + ) + + # Convert best parameters back to DataFrames + idx = 0 + for transform in range(1, num_transforms + 1): + for col in transform_columns: + shape_factors.loc[transform, col] = best_params[idx] + scale_factors.loc[transform, col] = best_params[idx + 1] + loc_factors.loc[transform, col] = best_params[idx + 2] + idx += 3 + + # Use the optimized parameters for final evaluation + prev_model = SINDY_delays_MI( + shape_factors, + scale_factors, + loc_factors, + system_data.index, + forcing, + response, + extra_verbose, + poly_order, + include_bias, + include_interaction, + windup_timesteps, + bibo_stable, + transform_dependent=transform_dependent, + transform_only=transform_only, + forcing_coef_constraints=forcing_coef_constraints, + transform_cache=_transform_cache, + ) + + # For bayesian and scipy.optimize methods, we're done with optimization + print("\nOptimization complete. Using optimized parameters for final model.") + final_model = SINDY_delays_MI( + shape_factors, + scale_factors, + loc_factors, + system_data.index, + forcing, + response, + True, + poly_order, + include_bias, + include_interaction, + windup_timesteps, + bibo_stable, + transform_dependent=transform_dependent, + transform_only=transform_only, + forcing_coef_constraints=forcing_coef_constraints, + transform_cache=_transform_cache, + ) + print("\nFinal model:\n") + try: + print(final_model["model"].print(precision=5)) + except Exception as e: + print(e) + print("R^2") + print(prev_model["error_metrics"]["r2"]) + print("shape factors") + print(shape_factors) + print("scale factors") + print(scale_factors) + print("location factors") + print(loc_factors) + print("\n") + results[num_transforms] = { + "final_model": final_model.copy(), + "shape_factors": shape_factors.copy(deep=True), + "scale_factors": scale_factors.copy(deep=True), + "loc_factors": loc_factors.copy(deep=True), + "windup_timesteps": windup_timesteps, + "dependent_columns": dependent_columns, + "independent_columns": independent_columns, + "transform_cache": _transform_cache, + } + + # check if the benefit from adding the last transformation is less than the early stopping threshold + if ( + num_transforms > init_transforms + and results[num_transforms]["final_model"]["error_metrics"]["r2"] + - results[num_transforms - 1]["final_model"]["error_metrics"]["r2"] + < early_stopping_threshold + ): + print( + "Last transformation added less than ", + early_stopping_threshold * 100, + " % to R2 score. Terminating early.", + ) + break + continue + + print("\nInitial model:\n") + try: + print(prev_model["model"].print(precision=5)) + print("R^2") + print(prev_model["error_metrics"]["r2"]) + except Exception as e: # and print the exception: + print(e) + pass + print("shape factors") + print(shape_factors) + print("scale factors") + print(scale_factors) + print("location factors") + print(loc_factors) + print("\n") + + if not verbose: + print("training ", end="") + + # no_improvement_last_time = False + for iterations in range(0, max_iter): + if not verbose and iterations % 5 == 0: + print(str(iterations) + ".", end="") + + if transform_dependent: + tuning_input = system_data.columns[ + (iterations // num_transforms) % len(system_data.columns) + ] # row = iter // width % height] + elif transform_only is not None: + tuning_input = transform_only[ + (iterations // num_transforms) % len(transform_only) + ] + else: + tuning_input = orig_forcing_columns[ + (iterations // num_transforms) % len(orig_forcing_columns) + ] # row = iter // width % height + tuning_line = ( + iterations % num_transforms + 1 + ) # col = % width (plus one because there's no zeroth transformation) + if verbose: + print( + str( + "tuning input: {i} | tuning transformation: {l:g}".format( + i=tuning_input, l=tuning_line + ) + ) + ) + + sooner_locs = loc_factors.copy(deep=True) + sooner_locs.loc[tuning_line, tuning_input] = float( + loc_factors.loc[tuning_line, tuning_input] - speed / 10 + ) + if sooner_locs[tuning_input][tuning_line] < 0: + sooner = {"error_metrics": {"r2": -1}} + else: + sooner = SINDY_delays_MI( + shape_factors, + scale_factors, + sooner_locs, + system_data.index, + forcing, + response, + extra_verbose, + poly_order, + include_bias, + include_interaction, + windup_timesteps, + bibo_stable, + transform_dependent=transform_dependent, + transform_only=transform_only, + forcing_coef_constraints=forcing_coef_constraints, + transform_cache=_transform_cache, + ) + + later_locs = loc_factors.copy(deep=True) + later_locs.loc[tuning_line, tuning_input] = float( + loc_factors.loc[tuning_line, tuning_input] + 1.01 * speed / 10 + ) + later = SINDY_delays_MI( + shape_factors, + scale_factors, + later_locs, + system_data.index, + forcing, + response, + extra_verbose, + poly_order, + include_bias, + include_interaction, + windup_timesteps, + bibo_stable, + transform_dependent=transform_dependent, + transform_only=transform_only, + forcing_coef_constraints=forcing_coef_constraints, + transform_cache=_transform_cache, + ) + + shape_up = shape_factors.copy(deep=True) + shape_up.loc[tuning_line, tuning_input] = float( + shape_factors.loc[tuning_line, tuning_input] * speed * 1.01 + ) + shape_upped = SINDY_delays_MI( + shape_up, + scale_factors, + loc_factors, + system_data.index, + forcing, + response, + extra_verbose, + poly_order, + include_bias, + include_interaction, + windup_timesteps, + bibo_stable, + transform_dependent=transform_dependent, + transform_only=transform_only, + forcing_coef_constraints=forcing_coef_constraints, + transform_cache=_transform_cache, + ) + + shape_down = shape_factors.copy(deep=True) + shape_down.loc[tuning_line, tuning_input] = float( + shape_factors.loc[tuning_line, tuning_input] / speed + ) + if shape_down[tuning_input][tuning_line] < 1: + shape_downed = { + "error_metrics": {"r2": -1} + } # return a score of negative one as this is illegal + else: + shape_downed = SINDY_delays_MI( + shape_down, + scale_factors, + loc_factors, + system_data.index, + forcing, + response, + extra_verbose, + poly_order, + include_bias, + include_interaction, + windup_timesteps, + bibo_stable, + transform_dependent=transform_dependent, + transform_only=transform_only, + forcing_coef_constraints=forcing_coef_constraints, + transform_cache=_transform_cache, + ) + + scale_up = scale_factors.copy(deep=True) + scale_up.loc[tuning_line, tuning_input] = float( + scale_factors.loc[tuning_line, tuning_input] * speed * 1.01 + ) + scaled_up = SINDY_delays_MI( + shape_factors, + scale_up, + loc_factors, + system_data.index, + forcing, + response, + extra_verbose, + poly_order, + include_bias, + include_interaction, + windup_timesteps, + bibo_stable, + transform_dependent=transform_dependent, + transform_only=transform_only, + forcing_coef_constraints=forcing_coef_constraints, + transform_cache=_transform_cache, + ) + + scale_down = scale_factors.copy(deep=True) + scale_down.loc[tuning_line, tuning_input] = float( + scale_factors.loc[tuning_line, tuning_input] / speed + ) + scaled_down = SINDY_delays_MI( + shape_factors, + scale_down, + loc_factors, + system_data.index, + forcing, + response, + extra_verbose, + poly_order, + include_bias, + include_interaction, + windup_timesteps, + bibo_stable, + transform_dependent=transform_dependent, + transform_only=transform_only, + forcing_coef_constraints=forcing_coef_constraints, + transform_cache=_transform_cache, + ) + + rounder_shape = shape_factors.copy(deep=True) + rounder_shape.loc[tuning_line, tuning_input] = shape_factors.loc[ + tuning_line, tuning_input + ] * (speed * 1.01) + rounder_scale = scale_factors.copy(deep=True) + rounder_scale.loc[tuning_line, tuning_input] = scale_factors.loc[ + tuning_line, tuning_input + ] / (speed * 1.01) + rounder = SINDY_delays_MI( + rounder_shape, + rounder_scale, + loc_factors, + system_data.index, + forcing, + response, + extra_verbose, + poly_order, + include_bias, + include_interaction, + windup_timesteps, + bibo_stable, + transform_dependent=transform_dependent, + transform_only=transform_only, + forcing_coef_constraints=forcing_coef_constraints, + transform_cache=_transform_cache, + ) + + sharper_shape = shape_factors.copy(deep=True) + sharper_shape.loc[tuning_line, tuning_input] = ( + shape_factors.loc[tuning_line, tuning_input] / speed + ) + if sharper_shape[tuning_input][tuning_line] < 1: + sharper = { + "error_metrics": {"r2": -1} + } # lower bound on shape to avoid inf + else: + sharper_scale = scale_factors.copy(deep=True) + sharper_scale.loc[tuning_line, tuning_input] = ( + scale_factors.loc[tuning_line, tuning_input] * speed + ) + sharper = SINDY_delays_MI( + sharper_shape, + sharper_scale, + loc_factors, + system_data.index, + forcing, + response, + extra_verbose, + poly_order, + include_bias, + include_interaction, + windup_timesteps, + bibo_stable, + transform_dependent=transform_dependent, + transform_only=transform_only, + forcing_coef_constraints=forcing_coef_constraints, + transform_cache=_transform_cache, + ) + + scores = [ + prev_model["error_metrics"]["r2"], + shape_upped["error_metrics"]["r2"], + shape_downed["error_metrics"]["r2"], + scaled_up["error_metrics"]["r2"], + scaled_down["error_metrics"]["r2"], + sooner["error_metrics"]["r2"], + later["error_metrics"]["r2"], + rounder["error_metrics"]["r2"], + sharper["error_metrics"]["r2"], + ] + + if ( + sooner["error_metrics"]["r2"] >= max(scores) + and sooner["error_metrics"]["r2"] + > improvement_threshold * prev_model["error_metrics"]["r2"] + ): + prev_model = sooner.copy() + loc_factors = sooner_locs.copy(deep=True) + elif ( + later["error_metrics"]["r2"] >= max(scores) + and later["error_metrics"]["r2"] + > improvement_threshold * prev_model["error_metrics"]["r2"] + ): + prev_model = later.copy() + loc_factors = later_locs.copy(deep=True) + elif ( + shape_upped["error_metrics"]["r2"] >= max(scores) + and shape_upped["error_metrics"]["r2"] + > improvement_threshold * prev_model["error_metrics"]["r2"] + ): + prev_model = shape_upped.copy() + shape_factors = shape_up.copy(deep=True) + elif ( + shape_downed["error_metrics"]["r2"] >= max(scores) + and shape_downed["error_metrics"]["r2"] + > improvement_threshold * prev_model["error_metrics"]["r2"] + ): + prev_model = shape_downed.copy() + shape_factors = shape_down.copy(deep=True) + elif ( + scaled_up["error_metrics"]["r2"] >= max(scores) + and scaled_up["error_metrics"]["r2"] + > improvement_threshold * prev_model["error_metrics"]["r2"] + ): + prev_model = scaled_up.copy() + scale_factors = scale_up.copy(deep=True) + elif ( + scaled_down["error_metrics"]["r2"] >= max(scores) + and scaled_down["error_metrics"]["r2"] + > improvement_threshold * prev_model["error_metrics"]["r2"] + ): + prev_model = scaled_down.copy() + scale_factors = scale_down.copy(deep=True) + elif ( + rounder["error_metrics"]["r2"] >= max(scores) + and rounder["error_metrics"]["r2"] + > improvement_threshold * prev_model["error_metrics"]["r2"] + ): + prev_model = rounder.copy() + shape_factors = rounder_shape.copy(deep=True) + scale_factors = rounder_scale.copy(deep=True) + elif ( + sharper["error_metrics"]["r2"] >= max(scores) + and sharper["error_metrics"]["r2"] + > improvement_threshold * prev_model["error_metrics"]["r2"] + ): + prev_model = sharper.copy() + shape_factors = sharper_shape.copy(deep=True) + scale_factors = sharper_scale.copy(deep=True) + # the middle was best, but it's bad, tighten up the bounds (if we're at the last tuning line of the last input) + if speed_idx >= len(speeds): + print("\n\noptimization complete\n\n") + break + speed = speeds[speed_idx] + if verbose: + print( + "\nprevious, shape up, shape down, scale up, scale down, sooner, later, rounder, sharper" + ) + print(scores) + print("speed") + print(speed) + print("shape factors") + print(shape_factors) + print("scale factors") + print(scale_factors) + print("location factors") + print(loc_factors) + print("iteration no:") + print(iterations) + print("model") + try: + prev_model["model"].print(precision=5) + except Exception as e: + print(e) + print("\n") + + final_model = SINDY_delays_MI( + shape_factors, + scale_factors, + loc_factors, + system_data.index, + forcing, + response, + True, + poly_order, + include_bias, + include_interaction, + windup_timesteps, + bibo_stable, + transform_dependent=transform_dependent, + transform_only=transform_only, + forcing_coef_constraints=forcing_coef_constraints, + transform_cache=_transform_cache, + ) + print("\nFinal model:\n") + try: + print(final_model["model"].print(precision=5)) + except Exception as e: + print(e) + print("R^2") + print(prev_model["error_metrics"]["r2"]) + print("shape factors") + print(shape_factors) + print("scale factors") + print(scale_factors) + print("location factors") + print(loc_factors) + print("\n") + results[num_transforms] = { + "final_model": final_model.copy(), + "shape_factors": shape_factors.copy(deep=True), + "scale_factors": scale_factors.copy(deep=True), + "loc_factors": loc_factors.copy(deep=True), + "windup_timesteps": windup_timesteps, + "dependent_columns": dependent_columns, + "independent_columns": independent_columns, + "transform_cache": _transform_cache, + } + + # check if the benefit from adding the last transformation is less than the early stopping threshold + if ( + num_transforms > init_transforms + and results[num_transforms]["final_model"]["error_metrics"]["r2"] + - results[num_transforms - 1]["final_model"]["error_metrics"]["r2"] + < early_stopping_threshold + ): + print( + "Last transformation added less than ", + early_stopping_threshold * 100, + " % to R2 score. Terminating early.", + ) + break + + return results diff --git a/modpods/transforms.py b/modpods/transforms.py new file mode 100644 index 0000000..40a8e4f --- /dev/null +++ b/modpods/transforms.py @@ -0,0 +1,205 @@ +import warnings +from collections import OrderedDict + +import numpy as np +import scipy.signal as signal +import scipy.stats as stats +from scipy.optimize import minimize + +# Suppress the specific AxesWarning from pysindy after import +warnings.filterwarnings( + "ignore", message=".*axes labeled for array with.*", module="pysindy" +) + + +# Bayesian optimization helper functions +def _expected_improvement(X, X_sample, Y_sample, gpr, xi=0.01): + """Expected Improvement acquisition function for Bayesian optimization.""" + mu, sigma = gpr.predict(X, return_std=True) + mu = mu.reshape(-1, 1) + sigma = sigma.reshape(-1, 1) + + mu_sample_opt = np.max(Y_sample) + + with np.errstate(divide="warn"): + imp = mu - mu_sample_opt - xi + Z = imp / sigma + ei = imp * stats.norm.cdf(Z) + sigma * stats.norm.pdf(Z) + ei[sigma == 0.0] = 0.0 + + return ei + + +def _propose_location(acquisition, X_sample, Y_sample, gpr, bounds, n_restarts=10): + """Propose next sampling point by optimizing acquisition function.""" + dim = X_sample.shape[1] + min_val = 1 + min_x = None + + def min_obj(X): + return -acquisition(X.reshape(-1, dim), X_sample, Y_sample, gpr).flatten() + + for x0 in np.random.uniform(bounds[:, 0], bounds[:, 1], size=(n_restarts, dim)): + res = minimize(min_obj, x0=x0, bounds=bounds, method="L-BFGS-B") + if res.fun < min_val: + min_val = res.fun + min_x = res.x + + return min_x.reshape(-1, 1) + + +# ============================================================================= +# Transform Cache - memoizes single-input gamma transforms to avoid recomputation +# ============================================================================= + + +class TransformCache: + """LRU cache for gamma-transformed time series. + + Caches results of convolving a forcing series with a gamma PDF kernel. + Keys are quantized (input_name, shape, scale, loc) tuples so near-identical + parameter sets reuse cached results. + """ + + def __init__(self, max_entries: int = 2000, quantization: float = 1e-6): + self._cache: "OrderedDict[tuple, np.ndarray]" = OrderedDict() + self.max_entries = max_entries + self.quantization = quantization + self.hits = 0 + self.misses = 0 + + def _quantize(self, value: float) -> float: + """Quantize a float to reduce near-duplicate keys.""" + if self.quantization <= 0: + return value + return round(value / self.quantization) * self.quantization + + def _make_key( + self, input_name: str, n: int, shape: float, scale: float, loc: float + ) -> tuple: + """Create a hashable cache key from input name and gamma params.""" + return ( + input_name, + n, + self._quantize(shape), + self._quantize(scale), + self._quantize(loc), + ) + + def get( + self, + input_name: str, + forcing_values: np.ndarray, + shape: float, + scale: float, + loc: float, + ) -> np.ndarray: + """Get cached transform or compute and cache it. + + Returns a COPY of the cached array to prevent mutation issues. + """ + n = len(forcing_values) + key = self._make_key(input_name, n, shape, scale, loc) + + if key in self._cache: + self.hits += 1 + # Move to end (most recently used) + self._cache.move_to_end(key) + return self._cache[key].copy() + + # Cache miss - compute the transform using FFT convolution + self.misses += 1 + shape_time = np.arange(0, n, 1) + gamma_kernel = stats.gamma.pdf(shape_time, shape, scale=scale, loc=loc) + result = signal.fftconvolve(forcing_values, gamma_kernel, mode="full")[:n] + + # Store in cache + self._cache[key] = result + + # Evict oldest if over capacity + if len(self._cache) > self.max_entries: + self._cache.popitem(last=False) + + return result.copy() # type: ignore[no-any-return] + + def clear(self): + """Clear the cache and reset counters.""" + self._cache.clear() + self.hits = 0 + self.misses = 0 + + def stats(self) -> dict: + """Return cache statistics.""" + total = self.hits + self.misses + hit_rate = self.hits / total if total > 0 else 0.0 + return { + "hits": self.hits, + "misses": self.misses, + "total": total, + "hit_rate": hit_rate, + "size": len(self._cache), + "max_entries": self.max_entries, + } + + def __repr__(self): + s = self.stats() + return f"TransformCache(hits={s['hits']}, misses={s['misses']}, hit_rate={s['hit_rate']:.2%}, size={s['size']})" + + +# Global cache instance used throughout the module +_transform_cache = TransformCache(max_entries=2000, quantization=1e-6) + + +def transform_inputs( + shape_factors, scale_factors, loc_factors, index, forcing, *, cache=None +): + """Vectorized implementation of transform_inputs for greater speed. + + Applies gamma PDF transformations to forcing inputs using FFT-based convolution + instead of element-wise iteration. Optional LRU cache avoids recomputation for + near-identical parameters during optimization. + + Args: + shape_factors: DataFrame of gamma shape parameters + scale_factors: DataFrame of gamma scale parameters + loc_factors: DataFrame of gamma location parameters + index: Time index + forcing: DataFrame of forcing inputs + cache: Optional TransformCache instance for memoization (default None) + """ + # original forcing columns -> columns of forcing that don't have _tr_ in their name + orig_forcing_columns = [col for col in forcing.columns if "_tr_" not in col] + + # how many rows of shape_factors do not contain NaNs? + num_transforms = int(shape_factors.count().iloc[0]) + + n = len(index) + + for input_col in orig_forcing_columns: + forcing_values = forcing[input_col].to_numpy(dtype=float) + + for transform_idx in range(1, num_transforms + 1): + col_name = f"{input_col}_tr_{transform_idx}" + + # Get gamma parameters + shape = float(shape_factors[input_col][transform_idx]) + scale = float(scale_factors[input_col][transform_idx]) + loc = float(loc_factors[input_col][transform_idx]) + + if cache is not None: + # Use cached transform + result = cache.get(input_col, forcing_values, shape, scale, loc) + else: + # Compute directly using FFT convolution (faster than np.convolve for large arrays) + shape_time = np.arange(0, n, 1) + gamma_kernel = stats.gamma.pdf(shape_time, shape, scale=scale, loc=loc) + result = signal.fftconvolve(forcing_values, gamma_kernel, mode="full")[ + :n + ] + + forcing.loc[:, col_name] = result + + # assert there are no NaNs in the forcing + if forcing.isnull().values.any(): + raise ValueError("Transform inputs produced NaN values") + return forcing diff --git a/modpods_backup.py b/modpods_backup.py deleted file mode 100644 index 06a3bf5..0000000 --- a/modpods_backup.py +++ /dev/null @@ -1,2685 +0,0 @@ -import pandas as pd -import numpy as np -import pysindy as ps -import scipy.stats as stats -from scipy import signal -from scipy.optimize import minimize -import matplotlib.pyplot as plt -import control as control -import networkx as nx -import sys -try: - import pyswmm # not a requirement for any other function -except ImportError: - pyswmm = None -import re -from sklearn.gaussian_process import GaussianProcessRegressor -from sklearn.gaussian_process.kernels import Matern -import warnings - -# Bayesian optimization helper functions -def expected_improvement(X, X_sample, Y_sample, gpr, xi=0.01): - """ - Computes the Expected Improvement at points X based on existing samples X_sample - and Y_sample using a Gaussian process surrogate model. - - Args: - X: Points at which EI shall be computed (m x d). - X_sample: Sample locations (n x d). - Y_sample: Sample values (n x 1). - gpr: A GaussianProcessRegressor fitted to samples. - xi: Exploitation-exploration trade-off parameter. - - Returns: - Expected improvements at points X. - """ - mu, sigma = gpr.predict(X, return_std=True) - mu = mu.reshape(-1, 1) - sigma = sigma.reshape(-1, 1) - - mu_sample_opt = np.max(Y_sample) - - with np.errstate(divide='warn'): - imp = mu - mu_sample_opt - xi - Z = imp / sigma - ei = imp * stats.norm.cdf(Z) + sigma * stats.norm.pdf(Z) - ei[sigma == 0.0] = 0.0 - - return ei - -def propose_location(acquisition, X_sample, Y_sample, gpr, bounds, n_restarts=25): - """ - Proposes the next sampling point by optimizing the acquisition function. - - Args: - acquisition: Acquisition function. - X_sample: Sample locations (n x d). - Y_sample: Sample values (n x 1). - gpr: A GaussianProcessRegressor fitted to samples. - bounds: Bounds for variables [(low, high), ...]. - n_restarts: Number of restarts for the acquisition function optimization. - - Returns: - Location of the next point to sample. - """ - dim = X_sample.shape[1] - min_val = 1 - min_x = None - - def min_obj(X): - # Minimize negative acquisition function - return -acquisition(X.reshape(-1, dim), X_sample, Y_sample, gpr).flatten() - - # Try n_restarts random starts - for x0 in np.random.uniform(bounds[:, 0], bounds[:, 1], size=(n_restarts, dim)): - res = minimize(min_obj, x0=x0, bounds=bounds, method='L-BFGS-B') - if res.fun < min_val: - min_val = res.fun[0] - min_x = res.x - - return min_x.reshape(-1, 1) - -def parameters_to_vector(shape_factors, scale_factors, loc_factors, transform_columns, num_transforms): - """Convert parameter DataFrames to a single vector for optimization.""" - params = [] - for transform in range(1, num_transforms + 1): - for col in transform_columns: - params.append(shape_factors.loc[transform, col]) - params.append(scale_factors.loc[transform, col]) - params.append(loc_factors.loc[transform, col]) - return np.array(params) - -def vector_to_parameters(params_vector, transform_columns, num_transforms): - """Convert parameter vector back to DataFrames.""" - shape_factors = pd.DataFrame(columns=transform_columns, index=range(1, num_transforms + 1)) - scale_factors = pd.DataFrame(columns=transform_columns, index=range(1, num_transforms + 1)) - loc_factors = pd.DataFrame(columns=transform_columns, index=range(1, num_transforms + 1)) - - idx = 0 - for transform in range(1, num_transforms + 1): - for col in transform_columns: - shape_factors.loc[transform, col] = params_vector[idx] - scale_factors.loc[transform, col] = params_vector[idx + 1] - loc_factors.loc[transform, col] = params_vector[idx + 2] - idx += 3 - - return shape_factors, scale_factors, loc_factors - -def bayesian_optimization_delay_io(system_data, dependent_columns, independent_columns, - windup_timesteps, poly_order, include_bias, include_interaction, - bibo_stable, transform_dependent, transform_only, - forcing_coef_constraints, num_transforms, max_iter, verbose): - """ - Bayesian optimization implementation for delay_io_train. - """ - # Determine which columns to transform - if transform_dependent: - transform_columns = system_data.columns.tolist() - elif transform_only is not None: - transform_columns = transform_only - else: - transform_columns = independent_columns - - # Define parameter bounds - # shape_factors: [1, 100], scale_factors: [0.1, 10], loc_factors: [0, 50] - n_params = len(transform_columns) * num_transforms * 3 # 3 params per (transform, column) pair - bounds = [] - for transform in range(1, num_transforms + 1): - for col in transform_columns: - bounds.append([1.0, 100.0]) # shape_factors bounds - bounds.append([0.1, 10.0]) # scale_factors bounds - bounds.append([0.0, 50.0]) # loc_factors bounds - bounds = np.array(bounds) - - # Define objective function - def objective_function(params_vector): - try: - shape_factors, scale_factors, loc_factors = vector_to_parameters( - params_vector, transform_columns, num_transforms) - - result = SINDY_delays_MI(shape_factors, scale_factors, loc_factors, - system_data.index, - system_data[independent_columns].copy(deep=True), - system_data[dependent_columns].copy(deep=True), - False, poly_order, include_bias, include_interaction, - windup_timesteps, bibo_stable, transform_dependent, - transform_only, forcing_coef_constraints) - - r2 = result['error_metrics']['r2'] - if verbose: - print(f"R² = {r2:.6f}") - return r2 - except Exception as e: - if verbose: - print(f"Evaluation failed: {e}") - return -1.0 # Return poor score for failed evaluations - - # Initialize with random samples - n_initial = min(10, max(5, max_iter // 5)) # 5-10 initial samples - X_sample = [] - Y_sample = [] - - if verbose: - print(f"Starting Bayesian optimization with {n_initial} initial samples...") - - # Generate initial random samples - for i in range(n_initial): - x = np.random.uniform(bounds[:, 0], bounds[:, 1]) - y = objective_function(x) - X_sample.append(x) - Y_sample.append(y) - if verbose: - print(f"Initial sample {i+1}/{n_initial}: R² = {y:.6f}") - - X_sample = np.array(X_sample) - Y_sample = np.array(Y_sample).reshape(-1, 1) - - # Bayesian optimization loop - best_r2 = np.max(Y_sample) - best_params = X_sample[np.argmax(Y_sample)] - - if verbose: - print(f"Initial best R² = {best_r2:.6f}") - - # Set up Gaussian Process - kernel = Matern(length_scale=1.0, nu=2.5) - gpr = GaussianProcessRegressor(kernel=kernel, alpha=1e-6, normalize_y=True, - n_restarts_optimizer=5, random_state=42) - - for iteration in range(max_iter - n_initial): - # Fit Gaussian Process - gpr.fit(X_sample, Y_sample.ravel()) - - # Find next point to evaluate - next_x = propose_location(expected_improvement, X_sample, Y_sample, gpr, bounds) - next_x = next_x.flatten() - - # Evaluate objective function - next_y = objective_function(next_x) - - if verbose: - print(f"BO iteration {iteration+1}/{max_iter-n_initial}: R² = {next_y:.6f}") - - # Add to samples - X_sample = np.append(X_sample, [next_x], axis=0) - Y_sample = np.append(Y_sample, next_y) - - # Update best - if next_y > best_r2: - best_r2 = next_y - best_params = next_x - if verbose: - print(f"New best R² = {best_r2:.6f}") - - # Convert best parameters back to DataFrames - best_shape, best_scale, best_loc = vector_to_parameters( - best_params, transform_columns, num_transforms) - - # Run final evaluation - final_result = SINDY_delays_MI(best_shape, best_scale, best_loc, - system_data.index, - system_data[independent_columns].copy(deep=True), - system_data[dependent_columns].copy(deep=True), - True, poly_order, include_bias, include_interaction, - windup_timesteps, bibo_stable, transform_dependent, - transform_only, forcing_coef_constraints) - - return final_result, best_shape, best_scale, best_loc - -# delay model builds differential equations relating the dependent variables to transformations of all the variables -# if there are no independent variables, then dependent_columns should be a list of all the columns in the dataframe -# and independent_columns should be an empty list -# by default, only the independent variables are transformed, but if transform_dependent is set to True, then the dependent variables are also transformed -# REQUIRES: -# a pandas dataframe, -# the column names of the dependent and indepdent variables, -# the number of timesteps to "wind up" the latent states, -# the initial number of transformations to use in the optimization, -# the maximum number of transformations to use in the optimization, -# the maximum number of iterations to use in the optimization -# and the order of the polynomial to use in the optimization -# bibo_stable: if true, the highest order output autocorrelation term is constrained to be negative -# RETURNS: -# models for each number of transformations from min to max -# NOTE: this code works for MIMO models, however, if output variables are dependent on each other -# poor simulation fidelity is likely due to their errors contributing to each other -# if the learned dynamics are highly accurate such that errors do not grow too large in any dependent variable, a MIMO model should work fine -# if you anticipate significant errors in the simulation of any dependent variable, you should use multiple MISO models instead -# as the model predicts derivatives, system_data must represent a *causal* system -# that is, forcing and the response to that forcing cannot occur at the same timestep -# it may be necessary for the user to shift the forcing data back to make the system causal (especially for time aggregated data like daily rainfall-runoff) -# forcing_coef_constraints is a dictionary of column name and then a 1, 0, or -1 depending on whether the coefficients of that variable should be positive, unconstrained, or negative -def delay_io_train(system_data, dependent_columns, independent_columns, - windup_timesteps=0,init_transforms=1, max_transforms=4, - max_iter=250, poly_order=3, transform_dependent=False, - verbose=False, extra_verbose=False, include_bias=False, - include_interaction=False, bibo_stable = False, - transform_only = None, forcing_coef_constraints=None, - early_stopping_threshold = 0.005, optimization_method="compass_search"): - forcing = system_data[independent_columns].copy(deep=True) - - orig_forcing_columns = forcing.columns - response = system_data[dependent_columns].copy(deep=True) - - results = dict() # to store the optimized models for each number of transformations - - if transform_dependent: - shape_factors = pd.DataFrame(columns = system_data.columns, index = range(init_transforms, max_transforms+1)) - shape_factors.iloc[0,:] = 1 # first transformation is [1,1,0] for each input - scale_factors = pd.DataFrame(columns = system_data.columns, index = range(init_transforms, max_transforms+1)) - scale_factors.iloc[0,:] = 1 # first transformation is [1,1,0] for each input - loc_factors = pd.DataFrame(columns = system_data.columns, index = range(init_transforms, max_transforms+1)) - loc_factors.iloc[0,:] = 0 # first transformation is [1,1,0] for each input - elif transform_only is not None: # the user provided a list of columns to transform - shape_factors = pd.DataFrame(columns = transform_only, index = range(init_transforms, max_transforms+1)) - shape_factors.iloc[0,:] = 1 # first transformation is [1,1,0] for each input - scale_factors = pd.DataFrame(columns = transform_only, index = range(init_transforms, max_transforms+1)) - scale_factors.iloc[0,:] = 1 # first transformation is [1,1,0] for each input - loc_factors = pd.DataFrame(columns = transform_only, index = range(init_transforms, max_transforms+1)) - loc_factors.iloc[0,:] = 0 # first transformation is [1,1,0] for each input - else: - # the transformation factors should be pandas dataframes where the index is which transformation it is and the columns are the variables - shape_factors = pd.DataFrame(columns = forcing.columns, index = range(init_transforms, max_transforms+1)) - shape_factors.iloc[0,:] = 1 # first transformation is [1,1,0] for each input - scale_factors = pd.DataFrame(columns = forcing.columns, index = range(init_transforms, max_transforms+1)) - scale_factors.iloc[0,:] = 1 # first transformation is [1,1,0] for each input - loc_factors = pd.DataFrame(columns = forcing.columns, index = range(init_transforms, max_transforms+1)) - loc_factors.iloc[0,:] = 0 # first transformation is [1,1,0] for each input - #print(shape_factors) - #print(scale_factors) - #print(loc_factors) - # first transformation is [1,1,0] for each input - ''' - shape_factors = np.ones(shape=(forcing.shape[1] , init_transforms) ) - scale_factors = np.ones(shape=(forcing.shape[1] , init_transforms) ) - loc_factors = np.zeros(shape=(forcing.shape[1] , init_transforms) ) - ''' - #speeds = list([500,200,50,10, 5,2, 1.1, 1.05,1.01]) - speeds = list([100,50,20,10,5,2,1.1,1.05,1.01]) # I don't have a great idea of what good values for these are yet - if transform_dependent: # just trying something - improvement_threshold = 1.001 # when improvements are tiny, tighten up the jumps - else: - improvement_threshold = 1.0 - - for num_transforms in range(init_transforms,max_transforms + 1): - print("num_transforms") - print(num_transforms) - - if (not num_transforms == init_transforms): # if we're not starting right now - # start dull - shape_factors.iloc[num_transforms-1,:] = 10*(num_transforms-1) # start with a broad peak centered at ten timesteps - scale_factors.iloc[num_transforms-1,:] = 1 - loc_factors.iloc[num_transforms-1,:] = 0 - if verbose: - print("starting factors for additional transformation\nshape\nscale\nlocation") - print(shape_factors) - print(scale_factors) - print(loc_factors) - - # Choose optimization method - if optimization_method == "bayesian": - if verbose: - print(f"Using Bayesian optimization for {num_transforms} transforms...") - - final_model, shape_factors_opt, scale_factors_opt, loc_factors_opt = bayesian_optimization_delay_io( - system_data, dependent_columns, independent_columns, - windup_timesteps, poly_order, include_bias, include_interaction, - bibo_stable, transform_dependent, transform_only, - forcing_coef_constraints, num_transforms, max_iter, verbose) - - # Update the factors with optimized values - shape_factors.iloc[:num_transforms,:] = shape_factors_opt.iloc[:num_transforms,:] - scale_factors.iloc[:num_transforms,:] = scale_factors_opt.iloc[:num_transforms,:] - loc_factors.iloc[:num_transforms,:] = loc_factors_opt.iloc[:num_transforms,:] - - else: # Default compass search optimization - if verbose: - print(f"Using compass search optimization for {num_transforms} transforms...") - - speed_idx = 0 - speed = speeds[speed_idx] - - prev_model = SINDY_delays_MI(shape_factors, scale_factors, loc_factors, system_data.index, - forcing, response,extra_verbose, poly_order , include_bias, - include_interaction,windup_timesteps,bibo_stable,transform_dependent=transform_dependent, - transform_only=transform_only,forcing_coef_constraints=forcing_coef_constraints) - - print("\nInitial model:\n") - try: - print(prev_model['model'].print(precision=5)) - print("R^2") - print(prev_model['error_metrics']['r2']) - except Exception as e: # and print the exception: - print(e) - pass - print("shape factors") - print(shape_factors) - print("scale factors") - print(scale_factors) - print("location factors") - print(loc_factors) - print("\n") - - if not verbose: - print("training ", end='') - - #no_improvement_last_time = False - for iterations in range(0,max_iter ): - if not verbose and iterations % 5 == 0: - print(str(iterations)+".", end='') - - if transform_dependent: - tuning_input = system_data.columns[(iterations // num_transforms) % len(system_data.columns)] # row = iter // width % height] - elif transform_only is not None: - tuning_input = transform_only[(iterations // num_transforms) % len(transform_only)] - else: - tuning_input = orig_forcing_columns[(iterations // num_transforms) % len(orig_forcing_columns)] # row = iter // width % height - tuning_line = iterations % num_transforms + 1 # col = % width (plus one because there's no zeroth transformation) - if verbose: - print(str("tuning input: {i} | tuning transformation: {l:g}".format(i=tuning_input,l=tuning_line))) - - - sooner_locs = loc_factors.copy(deep=True) - #sooner_locs[tuning_input][tuning_line] = float(loc_factors[tuning_input][tuning_line] - speed/10 ) - sooner_locs.loc[tuning_line,tuning_input] = float(loc_factors.loc[tuning_line,tuning_input] - speed/10) - if ( sooner_locs[tuning_input][tuning_line] < 0): - sooner = {'error_metrics':{'r2':-1}} - else: - sooner = SINDY_delays_MI(shape_factors ,scale_factors ,sooner_locs, - system_data.index, forcing, response, extra_verbose, poly_order , - include_bias, include_interaction,windup_timesteps,bibo_stable,transform_dependent=transform_dependent, - transform_only=transform_only,forcing_coef_constraints=forcing_coef_constraints) - - - later_locs = loc_factors.copy(deep=True) - #later_locs[tuning_input][tuning_line] = float ( loc_factors[tuning_input][tuning_line] + 1.01*speed/10 ) - later_locs.loc[tuning_line,tuning_input] = float(loc_factors.loc[tuning_line,tuning_input] + 1.01*speed/10) - later = SINDY_delays_MI(shape_factors , scale_factors,later_locs, - system_data.index, forcing, response, extra_verbose, poly_order , - include_bias, include_interaction,windup_timesteps,bibo_stable,transform_dependent=transform_dependent, - transform_only=transform_only,forcing_coef_constraints=forcing_coef_constraints) - - - shape_up = shape_factors.copy(deep=True) - #shape_up[tuning_input][tuning_line] = float ( shape_factors[tuning_input][tuning_line]*speed*1.01 ) - shape_up.loc[tuning_line,tuning_input] = float(shape_factors.loc[tuning_line,tuning_input]*speed*1.01) - shape_upped = SINDY_delays_MI(shape_up , scale_factors, loc_factors, - system_data.index, forcing, response, extra_verbose, poly_order , - include_bias, include_interaction,windup_timesteps,bibo_stable,transform_dependent=transform_dependent, - transform_only=transform_only,forcing_coef_constraints=forcing_coef_constraints) - - shape_down = shape_factors.copy(deep=True) - #shape_down[tuning_input][tuning_line] = float ( shape_factors[tuning_input][tuning_line]/speed ) - shape_down.loc[tuning_line,tuning_input] = float(shape_factors.loc[tuning_line,tuning_input]/speed) - if (shape_down[tuning_input][tuning_line] < 1): - shape_downed = {'error_metrics':{'r2':-1}} # return a score of negative one as this is illegal - else: - shape_downed = SINDY_delays_MI(shape_down , scale_factors, loc_factors, - system_data.index, forcing, response, extra_verbose, poly_order , - include_bias, include_interaction,windup_timesteps,bibo_stable,transform_dependent=transform_dependent, - transform_only=transform_only,forcing_coef_constraints=forcing_coef_constraints) - - scale_up = scale_factors.copy(deep=True) - #scale_up[tuning_input][tuning_line] = float( scale_factors[tuning_input][tuning_line]*speed*1.01 ) - scale_up.loc[tuning_line,tuning_input] = float(scale_factors.loc[tuning_line,tuning_input]*speed*1.01) - scaled_up = SINDY_delays_MI(shape_factors , scale_up, loc_factors, - system_data.index, forcing, response, extra_verbose, poly_order , - include_bias, include_interaction,windup_timesteps,bibo_stable,transform_dependent=transform_dependent, - transform_only=transform_only,forcing_coef_constraints=forcing_coef_constraints) - - - scale_down = scale_factors.copy(deep=True) - #scale_down[tuning_input][tuning_line] = float ( scale_factors[tuning_input][tuning_line]/speed ) - scale_down.loc[tuning_line,tuning_input] = float(scale_factors.loc[tuning_line,tuning_input]/speed) - scaled_down = SINDY_delays_MI(shape_factors , scale_down, loc_factors, - system_data.index, forcing, response, extra_verbose, poly_order , - include_bias, include_interaction,windup_timesteps,bibo_stable,transform_dependent=transform_dependent, - transform_only=transform_only,forcing_coef_constraints=forcing_coef_constraints) - - # rounder - rounder_shape = shape_factors.copy(deep=True) - #rounder_shape[tuning_input][tuning_line] = shape_factors[tuning_input][tuning_line]*(speed*1.01) - rounder_shape.loc[tuning_line,tuning_input] = shape_factors.loc[tuning_line,tuning_input]*(speed*1.01) - rounder_scale = scale_factors.copy(deep=True) - #rounder_scale[tuning_input][tuning_line] = scale_factors[tuning_input][tuning_line]/(speed*1.01) - rounder_scale.loc[tuning_line,tuning_input] = scale_factors.loc[tuning_line,tuning_input]/(speed*1.01) - rounder = SINDY_delays_MI(rounder_shape , rounder_scale, loc_factors, - system_data.index, forcing, response, extra_verbose, poly_order , - include_bias, include_interaction,windup_timesteps,bibo_stable,transform_dependent=transform_dependent, - transform_only=transform_only,forcing_coef_constraints=forcing_coef_constraints) - - # sharper - sharper_shape = shape_factors.copy(deep=True) - #sharper_shape[tuning_input][tuning_line] = shape_factors[tuning_input][tuning_line]/speed - sharper_shape.loc[tuning_line,tuning_input] = shape_factors.loc[tuning_line,tuning_input]/speed - if (sharper_shape[tuning_input][tuning_line] < 1): - sharper = {'error_metrics':{'r2':-1}} # lower bound on shape to avoid inf - else: - sharper_scale = scale_factors.copy(deep=True) - #sharper_scale[tuning_input][tuning_line] = scale_factors[tuning_input][tuning_line]*speed - sharper_scale.loc[tuning_line,tuning_input] = scale_factors.loc[tuning_line,tuning_input]*speed - sharper = SINDY_delays_MI(sharper_shape ,sharper_scale,loc_factors, - system_data.index, forcing, response, extra_verbose, poly_order , - include_bias, include_interaction,windup_timesteps,bibo_stable,transform_dependent=transform_dependent, - transform_only=transform_only,forcing_coef_constraints=forcing_coef_constraints) - - - - - scores = [prev_model['error_metrics']['r2'], shape_upped['error_metrics']['r2'], shape_downed['error_metrics']['r2'], - scaled_up['error_metrics']['r2'], scaled_down['error_metrics']['r2'], sooner['error_metrics']['r2'], - later['error_metrics']['r2'], rounder['error_metrics']['r2'], sharper['error_metrics']['r2'] ] - #print(scores) - - if (sooner['error_metrics']['r2'] >= max(scores) and sooner['error_metrics']['r2'] > improvement_threshold*prev_model['error_metrics']['r2']): - prev_model = sooner.copy() - loc_factors = sooner_locs.copy(deep=True) - elif (later['error_metrics']['r2'] >= max(scores) and later['error_metrics']['r2'] > improvement_threshold*prev_model['error_metrics']['r2']): - prev_model = later.copy() - loc_factors = later_locs.copy(deep=True) - elif(shape_upped['error_metrics']['r2'] >= max(scores) and shape_upped['error_metrics']['r2'] > improvement_threshold*prev_model['error_metrics']['r2']): - prev_model = shape_upped.copy() - shape_factors = shape_up.copy(deep=True) - elif(shape_downed['error_metrics']['r2'] >=max(scores) and shape_downed['error_metrics']['r2'] > improvement_threshold*prev_model['error_metrics']['r2']): - prev_model = shape_downed.copy() - shape_factors = shape_down.copy(deep=True) - elif(scaled_up['error_metrics']['r2'] >= max(scores) and scaled_up['error_metrics']['r2'] > improvement_threshold*prev_model['error_metrics']['r2']): - prev_model = scaled_up.copy() - scale_factors = scale_up.copy(deep=True) - elif(scaled_down['error_metrics']['r2'] >= max(scores) and scaled_down['error_metrics']['r2'] > improvement_threshold*prev_model['error_metrics']['r2']): - prev_model = scaled_down.copy() - scale_factors = scale_down.copy(deep=True) - elif (rounder['error_metrics']['r2'] >= max(scores) and rounder['error_metrics']['r2'] > improvement_threshold*prev_model['error_metrics']['r2']): - prev_model = rounder.copy() - shape_factors = rounder_shape.copy(deep=True) - scale_factors = rounder_scale.copy(deep=True) - elif (sharper['error_metrics']['r2'] >= max(scores) and sharper['error_metrics']['r2'] > improvement_threshold*prev_model['error_metrics']['r2']): - prev_model = sharper.copy() - shape_factors = sharper_shape.copy(deep=True) - scale_factors = sharper_scale.copy(deep=True) - # the middle was best, but it's bad, tighten up the bounds (if we're at the last tuning line of the last input) - - elif( num_transforms == tuning_line and tuning_input == shape_factors.columns[-1]): # no improvement transforming last column - #no_improvement_last_time=True - speed_idx = speed_idx + 1 - if verbose: - print("\n\ntightening bounds\n\n") - ''' - elif (num_transforms == tuning_line and tuning_input == orig_forcing_columns[0] and no_improvement_last_time): # no improvement next iteration (first column) - speed_idx = speed_idx + 1 - no_improvement_last_time=False - if verbose: - print("\n\ntightening bounds\n\n") - ''' - - if (speed_idx >= len(speeds)): - print("\n\noptimization complete\n\n") - break - speed = speeds[speed_idx] - if (verbose): - print("\nprevious, shape up, shape down, scale up, scale down, sooner, later, rounder, sharper") - print(scores) - print("speed") - print(speed) - print("shape factors") - print(shape_factors) - print("scale factors") - print(scale_factors) - print("location factors") - print(loc_factors) - print("iteration no:") - print(iterations) - print("model") - try: - prev_model['model'].print(precision=5) - except Exception as e: - print(e) - print("\n") - - - - final_model = SINDY_delays_MI(shape_factors, scale_factors ,loc_factors,system_data.index, forcing, response, True, poly_order , - include_bias, include_interaction,windup_timesteps,bibo_stable,transform_dependent=transform_dependent, - transform_only=transform_only,forcing_coef_constraints=forcing_coef_constraints) - print("\nFinal model:\n") - try: - print(final_model['model'].print(precision=5)) - except Exception as e: - print(e) - print("R^2") - print(prev_model['error_metrics']['r2']) - print("shape factors") - print(shape_factors) - print("scale factors") - print(scale_factors) - print("location factors") - print(loc_factors) - print("\n") - results[num_transforms] = {'final_model':final_model.copy(), - 'shape_factors':shape_factors.copy(deep=True), - 'scale_factors':scale_factors.copy(deep=True), - 'loc_factors':loc_factors.copy(deep=True), - 'windup_timesteps':windup_timesteps, - 'dependent_columns':dependent_columns, - 'independent_columns':independent_columns} - - # check if the benefit from adding the last transformation is less than the early stopping threshold - if num_transforms > init_transforms and results[num_transforms]['final_model']['error_metrics']['r2'] - results[num_transforms-1]['final_model']['error_metrics']['r2'] < early_stopping_threshold: - print("Last transformation added less than ", early_stopping_threshold*100," % to R2 score. Terminating early.") - break - - return results - - -def SINDY_delays_MI(shape_factors, scale_factors, loc_factors, index, forcing, response, final_run, - poly_degree, include_bias, include_interaction,windup_timesteps,bibo_stable=False, - transform_dependent=False,transform_only=None, forcing_coef_constraints=None): - if transform_only is not None: - transformed_forcing = transform_inputs(shape_factors, scale_factors,loc_factors, index, forcing.loc[:,transform_only]) - untransformed_forcing = forcing.drop(columns=transform_only) - # combine forcing and transformed forcing column-wise - forcing = pd.concat((untransformed_forcing,transformed_forcing),axis='columns') - else: - forcing = transform_inputs(shape_factors, scale_factors,loc_factors, index, forcing) - - feature_names = response.columns.tolist() + forcing.columns.tolist() - - # SINDy - if (not bibo_stable and forcing_coef_constraints is None): # no constraints, normal mode - model = ps.SINDy( - differentiation_method= ps.FiniteDifference(), - feature_library=ps.PolynomialLibrary(degree=poly_degree,include_bias = include_bias, include_interaction=include_interaction), - optimizer = ps.STLSQ(threshold=0), - feature_names = feature_names - ) - elif (forcing_coef_constraints is not None and not bibo_stable): - library = ps.PolynomialLibrary(degree=poly_degree,include_bias = include_bias, include_interaction=include_interaction) - total_train = pd.concat((response,forcing), axis='columns') - library.fit([ps.AxesArray(total_train,{"ax_sample":0,"ax_coord":1})]) - n_features = library.n_output_features_ - n_targets = len(response.columns) - constraint_rhs = np.zeros((n_features,)) # every feature is constrained - # one row per constraint, one column per coefficient - constraint_lhs = np.zeros((n_features , n_targets*n_features ) ) - - # now implement the forcing coefficient constraints - for i, col in enumerate(feature_names): - for key in forcing_coef_constraints.keys(): - if key in col: - constraint_lhs[i, i] = -forcing_coef_constraints[key] - # invert the sign because the eqn is written as "leq 0" - - model = ps.SINDy( - differentiation_method= ps.FiniteDifference(), - feature_library=ps.PolynomialLibrary(degree=poly_degree,include_bias = include_bias, include_interaction=include_interaction), - optimizer = ps.STLSQ(threshold=0), - feature_names = feature_names - ) - elif (bibo_stable): # highest order output autocorrelation is constrained to be negative - #import cvxpy - #run_cvxpy= True - # Figure out how many library features there will be - library = ps.PolynomialLibrary(degree=poly_degree,include_bias = include_bias, include_interaction=include_interaction) - total_train = pd.concat((response,forcing), axis='columns') - library.fit([ps.AxesArray(total_train,{"ax_sample":0,"ax_coord":1})]) - n_features = library.n_output_features_ - #print(f"Features ({n_features}):", library.get_feature_names(input_features=total_train.columns)) - feature_names = library.get_feature_names(input_features=total_train.columns) - # Set constraints - n_targets = total_train.shape[1] # not sure what targets means after reading through the pysindy docs - #print("n_targets") - #print(n_targets) - constraint_rhs = np.zeros((len(response.columns),1)) - # one row per constraint, one column per coefficient - constraint_lhs = np.zeros((len(response.columns) , n_features )) - - #print(constraint_rhs) - #print(constraint_lhs) - # constrain the highest order output autocorrelation to be negative - # this indexing is only right for include_interaction=False, include_bias=False, and pure polynomial library - # for more complex libraries, some conditional logic will be needed to grab the right column - constraint_lhs[:,-len(forcing.columns)-len(response.columns):-len(forcing.columns)] = 1 - # leq 0 - #print("constraint lhs") - #print(constraint_lhs) - - # forcing_coef_constraints only implemented for bibo stable MISO models right now - if forcing_coef_constraints is not None: - n_targets = len(response.columns) - constraint_rhs = np.zeros((n_features,)) # every feature is constrained - # one row per constraint, one column per coefficient - constraint_lhs = np.zeros((n_features , n_targets*n_features ) ) - # bibo stability, set the highest order output autocorrelation to be negative for each response variable - # the index corresponds to the last entry in "feature_names" which includes the name of the response column - highest_power_col_idx = 0 - for i, col in enumerate(feature_names): - if response.columns[0] in col: - highest_power_col_idx = i - constraint_lhs[0, highest_power_col_idx] = 1 # first row, highest power of the response variable - - # now implement the forcing coefficient constraints - for i, col in enumerate(feature_names): - for key in forcing_coef_constraints.keys(): - if key in col: - constraint_lhs[i, i] = -forcing_coef_constraints[key] - # invert the sign because the eqn is written as "leq 0" - '''' - print(forcing.columns) - forcing_constraints_array = np.ndarray(shape=(1,len(forcing.columns))) - for i, col in enumerate(forcing.columns): - if col in forcing_coef_constraints.keys(): # invert the sign because the eqn is written as "leq 0" - forcing_constraints_array[0,i] = -forcing_coef_constraints[col] - elif str(col).replace('_tr_1','') in forcing_coef_constraints.keys(): - forcing_constraints_array[0,i] = -forcing_coef_constraints[str(col).replace('_tr_1','')] - elif str(col).replace('_tr_2','') in forcing_coef_constraints.keys(): - forcing_constraints_array[0,i] = -forcing_coef_constraints[str(col).replace('_tr_2','')] - elif str(col).replace('_tr_3','') in forcing_coef_constraints.keys(): - forcing_constraints_array[0,i] = -forcing_coef_constraints[str(col).replace('_tr_3','')] - else: - forcing_constraints_array[0,i] = 0 - - for row in range(n_targets, n_features): - constraint_lhs[row, row] = forcing_constraints_array[0,row - n_targets] - ''' - - # constrain the highest order output autocorrelation to be negative - # this indexing is only right for include_interaction=False, include_bias=False, and pure polynomial library - # for more complex libraries, some conditional logic will be needed to grab the right column - #constraint_lhs[:n_targets,-len(forcing.columns)-len(response.columns):-len(forcing.columns)] = 1 - - #print(forcing_constraints_array) - - #print('constraint lhs') - #print(constraint_lhs) - #print('constraint rhs') - #print(constraint_rhs) - - - model = ps.SINDy( - differentiation_method= ps.FiniteDifference(), - feature_library=ps.PolynomialLibrary(degree=poly_degree,include_bias = include_bias, include_interaction=include_interaction), - optimizer = ps.STLSQ(threshold=0), - feature_names = feature_names - ) - if transform_dependent: - # combine response and forcing into one dataframe - total_train = pd.concat((response,forcing), axis='columns') - total_train = transform_inputs(shape_factors, scale_factors,loc_factors, index, total_train) - # remove the columns in total_train that are already in response (just want to keep the transformed forcing) - total_train = total_train.drop(columns = response.columns) - feature_names = response.columns.tolist() + total_train.columns.tolist() - - # need to add constraints such that variables don't depend on their own past values (but they can have autocorrelations) - - - library = ps.PolynomialLibrary(degree=poly_degree,include_bias = include_bias, include_interaction=include_interaction) - library_terms = pd.concat((total_train,response), axis='columns') - library.fit([ps.AxesArray(library_terms,{"ax_sample":0,"ax_coord":1})]) - n_features = library.n_output_features_ - #print(f"Features ({n_features}):", library.get_feature_names()) - # Set constraints - n_targets = response.shape[1] # not sure what targets means after reading through the pysindy docs - - constraint_rhs = np.zeros((n_targets,)) - # one row per constraint, one column per coefficient - constraint_lhs = np.zeros((n_targets , n_features*n_targets)) - # for bibo stability, starting guess is that each dependent variable is negatively autocorrelated and depends on no other variable - if bibo_stable: - initial_guess = np.zeros((n_targets,n_features)) - for idx in range(0,n_targets): - initial_guess[idx,idx] = -1 - else: - initial_guess = None - #print(constraint_rhs) - #print(constraint_lhs) - # set the coefficient on a variable's own transformed value to 0 - for idx in range(0,n_targets): - constraint_lhs[idx,(idx+1)*n_features - n_targets + idx] = 1 - - #print("constraint lhs") - #print(constraint_lhs) - - model = ps.SINDy( - differentiation_method= ps.FiniteDifference(), - feature_library=library, - optimizer = ps.SR3(threshold=0, thresholder = "l0", - nu = 10e9, initial_guess = initial_guess, - constraint_lhs=constraint_lhs, - constraint_rhs = constraint_rhs, - inequality_constraints=False, - max_iter=10000), - feature_names = feature_names - ) - - try: - # windup latent states (if your windup is too long, this will error) - model.fit(response.values[windup_timesteps:,:], t = np.arange(0,len(index),1)[windup_timesteps:], u = total_train.values[windup_timesteps:,:]) - r2 = model.score(response.values[windup_timesteps:,:],t=np.arange(0,len(index),1)[windup_timesteps:],u=total_train.values[windup_timesteps:,:]) # training data score - except Exception as e: # and print the exception - print("Exception in model fitting, returning r2=-1\n") - print(e) - error_metrics = {"MAE":[False],"RMSE":[False],"NSE":[False],"alpha":[False],"beta":[False],"HFV":[False],"HFV10":[False],"LFV":[False],"FDC":[False],"r2":-1} - return {"error_metrics": error_metrics, "model": None, "simulated": False, "response": response, "forcing": forcing, "index": index,"diverged":False} - - - else: - try: - # windup latent states (if your windup is too long, this will error) - model.fit(response.values[windup_timesteps:,:], t = np.arange(0,len(index),1)[windup_timesteps:], u = forcing.values[windup_timesteps:,:]) - r2 = model.score(response.values[windup_timesteps:,:],t=np.arange(0,len(index),1)[windup_timesteps:],u=forcing.values[windup_timesteps:,:]) # training data score - except Exception as e: # and print the exception - print("Exception in model fitting, returning r2=-1\n") - print(e) - error_metrics = {"MAE":[False],"RMSE":[False],"NSE":[False],"alpha":[False],"beta":[False],"HFV":[False],"HFV10":[False],"LFV":[False],"FDC":[False],"r2":-1} - return {"error_metrics": error_metrics, "model": None, "simulated": False, "response": response, "forcing": forcing, "index": index,"diverged":False} - # r2 is how well we're doing across all the outputs. that's actually good to keep model accuracy lumped because that's what makes most sense to drive the optimization - # even though the metrics we'll want to evaluate models on are individual output accuracy - #print("training R^2", r2) - #model.print(precision=5) - - # return false for things not evaluated / don't exist - error_metrics = {"MAE":[False],"RMSE":[False],"NSE":[False],"alpha":[False],"beta":[False],"HFV":[False],"HFV10":[False],"LFV":[False],"FDC":[False],"r2":r2} - simulated = False - if (final_run): # only simulate final runs because it's slow - try: #once in high volume training put this back in, but want to see the errors during development - if transform_dependent: - simulated = model.simulate(response.values[windup_timesteps,:],t=np.arange(0,len(index),1)[windup_timesteps:],u=total_train.values[windup_timesteps:,:]) - else: - simulated = model.simulate(response.values[windup_timesteps,:],t=np.arange(0,len(index),1)[windup_timesteps:],u=forcing.values[windup_timesteps:,:]) - mae = list() - rmse = list() - nse = list() - alpha = list() - beta = list() - hfv = list() - hfv10 = list() - lfv = list() - fdc = list() - for col_idx in range(0,len(response.columns)): # univariate performance metrics - error = response.values[windup_timesteps+1:,col_idx]-simulated[:,col_idx] - - #print("error") - #print(error) - # nash sutcliffe efficiency between response and simulated - mae.append(np.mean(np.abs(error))) - rmse.append(np.sqrt(np.mean(error**2 ) )) - #print("mean measured = ", np.mean(response.values[windup_timesteps+1:,col_idx] )) - #print("sum of squared error between measured and model = ", np.sum((error)**2 )) - #print("sum of squared error between measured and mean of measured = ", np.sum((response.values[windup_timesteps+1:,col_idx]-np.mean(response.values[windup_timesteps+1:,col_idx] ) )**2 )) - nse.append(1 - np.sum((error)**2 ) / np.sum((response.values[windup_timesteps+1:,col_idx]-np.mean(response.values[windup_timesteps+1:,col_idx] ) )**2 ) ) - alpha.append(np.std(simulated[:,col_idx])/np.std(response.values[windup_timesteps+1:,col_idx])) - beta.append(np.mean(simulated[:,col_idx])/np.mean(response.values[windup_timesteps+1:,col_idx])) - hfv.append(100*np.sum(np.sort(simulated[:,col_idx])[-int(0.02*len(index)):]-np.sort(response.values[windup_timesteps+1:,col_idx])[-int(0.02*len(index)):])/np.sum(np.sort(response.values[windup_timesteps+1:,col_idx])[-int(0.02*len(index)):])) - hfv10.append(100*np.sum(np.sort(simulated[:,col_idx])[-int(0.1*len(index)):]-np.sort(response.values[windup_timesteps+1:,col_idx])[-int(0.1*len(index)):])/np.sum(np.sort(response.values[windup_timesteps+1:,col_idx])[-int(0.1*len(index)):])) - lfv.append(100*np.sum(np.sort(simulated[:,col_idx])[-int(0.3*len(index)):]-np.sort(response.values[windup_timesteps+1:,col_idx])[-int(0.3*len(index)):])/np.sum(np.sort(response.values[windup_timesteps+1:,col_idx])[-int(0.3*len(index)):])) - fdc.append(100*(np.log10(np.sort(simulated[:,col_idx])[int(0.2*len(simulated))]) - - np.log10(np.sort(simulated[:,col_idx])[int(0.7*len(simulated))]) - - np.log10(np.sort(response.values[windup_timesteps+1:,col_idx])[int(0.2*len(simulated))]) - + np.log10(np.sort(response.values[windup_timesteps+1:,col_idx])[int(0.7*len(simulated))]) ) - / np.log10(np.sort(response.values[windup_timesteps+1:,col_idx])[int(0.2*len(simulated))]) - - np.log10(np.sort(response.values[windup_timesteps+1:,col_idx])[int(0.7*len(simulated))])) - - print("MAE = ", mae) - print("RMSE = ", rmse) - print("NSE = ", nse) - # alpha nse decomposition due to gupta et al 2009 - print("alpha = ", alpha) - print("beta = ", beta) - # top 2% peak flow bias (HFV) due to yilmaz et al 2008 - print("HFV = ", hfv) - # top 10% peak flow bias (HFV) due to yilmaz et al 2008 - print("HFV10 = ", hfv10) - # 30% low flow bias (LFV) due to yilmaz et al 2008 - print("LFV = ", lfv) - # bias of FDC midsegment slope due to yilmaz et al 2008 - print("FDC = ", fdc) - # compile all the error metrics into a dictionary - error_metrics = {"MAE":mae,"RMSE":rmse,"NSE":nse,"alpha":alpha,"beta":beta,"HFV":hfv,"HFV10":hfv10,"LFV":lfv,"FDC":fdc,"r2":r2} - - except Exception as e: # and print the exception: - print("Exception in simulation\n") - print(e) - error_metrics = {"MAE":[np.NAN],"RMSE":[np.NAN],"NSE":[np.NAN],"alpha":[np.NAN],"beta":[np.NAN],"HFV":[np.NAN],"HFV10":[np.NAN],"LFV":[np.NAN],"FDC":[np.NAN],"r2":r2} - - return {"error_metrics": error_metrics, "model": model, "simulated": response[1:], "response": response, "forcing": forcing, "index": index,"diverged":True} - - - - return {"error_metrics": error_metrics, "model": model, "simulated": simulated, "response": response, "forcing": forcing, "index": index,"diverged":False} - #return [r2, model, mae, rmse, index, simulated , response , forcing] - - - -def transform_inputs(shape_factors, scale_factors, loc_factors,index, forcing): - # original forcing columns -> columns of forcing that don't have _tr_ in their name - orig_forcing_columns = [col for col in forcing.columns if "_tr_" not in col] - #print("original forcing columns = ", orig_forcing_columns) - # how many rows of shape_factors do not contain NaNs? - num_transforms = shape_factors.count().iloc[0] - #print("num_transforms = ", num_transforms) - #print("forcing at beginning of transform inputs") - #print(forcing) - shape_time = np.arange(0,len(index),1) - for input in orig_forcing_columns: # which input are we talking about? - for transform_idx in range(1,num_transforms + 1): # which transformation of that input are we talking about? - # if the column doesn't exist, create it - if (str(str(input) + "_tr_" + str(transform_idx)) not in forcing.columns): - forcing.loc[:,str(str(input) + "_tr_" + str(transform_idx))] = 0.0 - # now, fill it with zeros (need to reset between different transformation shape factors) - forcing[str(str(input) + "_tr_" + str(transform_idx))].values[:] = float(0.0) - #print(forcing) - for idx in range(0,len(index)): # timestep - if (abs(forcing[input].iloc[idx]) > 10**-6): # when nonzero forcing occurs - if (idx == int(0)): - forcing[str(str(input) + "_tr_" + str(transform_idx))].values[idx:] += forcing[input].values[idx]*stats.gamma.pdf(shape_time, shape_factors[input][transform_idx], scale=scale_factors[input][transform_idx], loc = loc_factors[input][transform_idx]) - else: - forcing[str(str(input) + "_tr_" + str(transform_idx))].values[idx:] += forcing[input].values[idx]*stats.gamma.pdf(shape_time[:-idx], shape_factors[input][transform_idx], scale=scale_factors[input][transform_idx], loc = loc_factors[input][transform_idx]) - - #print("forcing at end of transform inputs") - #print(forcing) - # assert there are no NaNs in the forcing - assert(forcing.isnull().values.any() == False) - return forcing - -# REQUIRES: the output of delay_io_train, starting value of otuput, forcing timeseries -# EFFECTS: returns a simulated response given forcing and a model -# REQUIRED EDITS: not written to accomodate transform_dependent yet -def delay_io_predict(delay_io_model, system_data, num_transforms=1,evaluation=False , windup_timesteps=None): - if windup_timesteps is None: # user didn't specify windup timesteps, use what the model trained with. - windup_timesteps = delay_io_model[num_transforms]['windup_timesteps'] - forcing = system_data[delay_io_model[num_transforms]['independent_columns']].copy(deep=True) - response = system_data[delay_io_model[num_transforms]['dependent_columns']].copy(deep=True) - - transformed_forcing = transform_inputs(shape_factors=delay_io_model[num_transforms]['shape_factors'], - scale_factors=delay_io_model[num_transforms]['scale_factors'], - loc_factors=delay_io_model[num_transforms]['loc_factors'], - index=system_data.index,forcing=forcing) - try: - prediction = delay_io_model[num_transforms]['final_model']['model'].simulate(system_data[delay_io_model[num_transforms]['dependent_columns']].iloc[windup_timesteps,:], - t=np.arange(0,len(system_data.index),1)[windup_timesteps:], - u=transformed_forcing[windup_timesteps:]) - except Exception as e: # and print the exception: - print("Exception in simulation\n") - print(e) - print("diverged.") - error_metrics = {"MAE":[np.NAN],"RMSE":[np.NAN],"NSE":[np.NAN],"alpha":[np.NAN],"beta":[np.NAN],"HFV":[np.NAN],"HFV10":[np.NAN],"LFV":[np.NAN],"FDC":[np.NAN]} - return {'prediction':np.NAN*np.ones(shape=response[windup_timesteps+1:].shape), 'error_metrics':error_metrics,"diverged":True} - - # return all the error metrics if the prediction is being evaluated against known measurements - if (evaluation): - try: - mae = list() - rmse = list() - nse = list() - alpha = list() - beta = list() - hfv = list() - hfv10 = list() - lfv = list() - fdc = list() - for col_idx in range(0,len(response.columns)): # univariate performance metrics - error = response.values[windup_timesteps+1:,col_idx]-prediction[:,col_idx] - - initial_error_length = len(error) - error = error[~np.isnan(error)] - if (len(error) < 0.75*initial_error_length): - print("WARNING: More than 25% of the entries in error were NaN") - - #print("error") - #print(error) - # nash sutcliffe efficiency between response and prediction - mae.append(np.mean(np.abs(error))) - rmse.append(np.sqrt(np.mean(error**2 ) )) - #print("mean measured = ", np.mean(response.values[windup_timesteps+1:,col_idx] )) - #print("sum of squared error between measured and model = ", np.sum((error)**2 )) - #print("sum of squared error between measured and mean of measured = ", np.sum((response.values[windup_timesteps+1:,col_idx]-np.mean(response.values[windup_timesteps+1:,col_idx] ) )**2 )) - nse.append(1 - np.sum((error)**2 ) / np.sum((response.values[windup_timesteps+1:,col_idx]-np.mean(response.values[windup_timesteps+1:,col_idx] ) )**2 ) ) - alpha.append(np.std(prediction[:,col_idx])/np.std(response.values[windup_timesteps+1:,col_idx])) - beta.append(np.mean(prediction[:,col_idx])/np.mean(response.values[windup_timesteps+1:,col_idx])) - hfv.append(np.sum(np.sort(prediction[:,col_idx])[-int(0.02*len(system_data.index)):])/np.sum(np.sort(response.values[windup_timesteps+1:,col_idx])[-int(0.02*len(system_data.index)):])) - hfv10.append(np.sum(np.sort(prediction[:,col_idx])[-int(0.1*len(system_data.index)):])/np.sum(np.sort(response.values[windup_timesteps+1:,col_idx])[-int(0.1*len(system_data.index)):])) - lfv.append(np.sum(np.sort(prediction[:,col_idx])[:int(0.3*len(system_data.index))])/np.sum(np.sort(response.values[windup_timesteps+1:,col_idx])[:int(0.3*len(system_data.index))])) - fdc.append(np.mean(np.sort(prediction[:,col_idx])[-int(0.6*len(system_data.index)):-int(0.4*len(system_data.index))])/np.mean(np.sort(response.values[windup_timesteps+1:,col_idx])[-int(0.6*len(system_data.index)):-int(0.4*len(system_data.index))])) - - - print("MAE = ", mae) - print("RMSE = ", rmse) - - print("NSE = ", nse) - # alpha nse decomposition due to gupta et al 2009 - print("alpha = ", alpha) - print("beta = ", beta) - # top 2% peak flow bias (HFV) due to yilmaz et al 2008 - print("HFV = ", hfv) - # top 10% peak flow bias (HFV) due to yilmaz et al 2008 - print("HFV10 = ", hfv10) - # 30% low flow bias (LFV) due to yilmaz et al 2008 - print("LFV = ", lfv) - # bias of FDC midsegment slope due to yilmaz et al 2008 - print("FDC = ", fdc) - # compile all the error metrics into a dictionary - error_metrics = {"MAE":mae,"RMSE":rmse,"NSE":nse,"alpha":alpha,"beta":beta,"HFV":hfv,"HFV10":hfv10,"LFV":lfv,"FDC":fdc} - # omit r2 here because it doesn't mean the same thing as it does for training, would be misleading. - # r2 in training expresses how much of the derivative is predicted by the model, whereas in evaluation it expresses how much of the response is predicted by the model - - return {'prediction':prediction, 'error_metrics':error_metrics,"diverged":False} - except Exception as e: # and print the exception: - print(e) - print("Simulation diverged.") - error_metrics = {"MAE":[np.NAN],"RMSE":[np.NAN],"NSE":[np.NAN],"alpha":[np.NAN],"beta":[np.NAN],"HFV":[np.NAN],"HFV10":[np.NAN],"LFV":[np.NAN],"FDC":[np.NAN],"diverged":True} - - return {'prediction':prediction, 'error_metrics':error_metrics} - else: - error_metrics = {"MAE":[np.NAN],"RMSE":[np.NAN],"NSE":[np.NAN],"alpha":[np.NAN],"beta":[np.NAN],"HFV":[np.NAN],"HFV10":[np.NAN],"LFV":[np.NAN],"FDC":[np.NAN]} - return {'prediction':prediction, 'error_metrics':error_metrics,"diverged":False} - - - - -### the functions below are for generating LTI systems directly from data (aka system identification) - - -# the function below returns an LTI system (in the matrices A, B, and C) that mimic the shape of a given gamma distribution -# scaling should be correct, but need to verify that -# max state dim, resolution, and max iterations could be icnrased to improve accuracy -def lti_from_gamma(shape, scale, location,dt=0,desired_NSE = 0.999,verbose=False, - max_state_dim=50,max_iterations=200, max_pole_speed = 5, min_pole_speed = 0.01): - # a pole of speed -5 decays to less than 1% of it's value after one timestep - # a pole of speed -0.01 decays to more than 99% of it's value after one timestep - - # i've assumed here that gamma pdf is defined the same as in matlab - # if that's not true testing will show it soon enough - t50 = shape*scale + location # center of mass - skewness = 2 / np.sqrt(shape) - total_time_base = 2*t50 # not that this contains the full shape, but if we fit this much of the curve perfectly we'll be close enough - #resolution = (t50)/((skewness + location)) # make this coarser for faster debugging - resolution = (t50)/(10*(skewness + location)) # production version - - #resolution = 1/ skewness - decay_rate = 1 / resolution - decay_rate = np.clip(decay_rate ,min_pole_speed, max_pole_speed) - state_dim = int(np.floor(total_time_base*decay_rate)) # this keeps the time base fixed for a given decay rate - if state_dim > max_state_dim: - state_dim = max_state_dim - decay_rate = state_dim / total_time_base - resolution = 1 / decay_rate - if state_dim < 1: - state_dim = 1 - decay_rate = state_dim / total_time_base - resolution = 1 / decay_rate - - decay_rate = np.clip(decay_rate ,min_pole_speed, max_pole_speed) - - if verbose: - print("state dimension is ",state_dim) - print("decay rate is ",decay_rate) - print("total time base is ",total_time_base) - print("resolution is", resolution) - - - # make the timestep one so that the relative error is correct (dt too small makes error bigger than written) - #t = np.linspace(0,3*total_time_base,1000) - #desired_error = desired_error / dt - ''' - if dt > 0: # true if numeric - t = np.arange(0,2*total_time_base,dt) - else: - t= np.linspace(0,2*total_time_base,num=200) - ''' - t = np.linspace(0,2*total_time_base,num=200) - - #if verbose: - # print("dt is ",dt) - # print("scaled desired error is ",desired_error) - - gam = stats.gamma.pdf(t,shape,location,scale) - - # A is a cascade with the appropriate decay rate - A = decay_rate*np.diag(np.ones((state_dim-1)) , -1) - decay_rate*np.diag(np.ones((state_dim)),0) - # influence enters at the top state only - B = np.concatenate((np.ones((1,1)),np.zeros((state_dim-1,1)))) - # contributions of states to the output will be scaled to match the gamma distribution - C = np.ones((1,state_dim))*max(gam) - lti_sys = control.ss(A,B,C,0) - - lti_approx = control.impulse_response(lti_sys,t) - ''' - error = np.sum(np.abs(gam - lti_approx.y)) - if(verbose): - print("initial error") - print(error) - #print("desired error") - #print(max(gam)) - #print(desired_error) - ''' - NSE = 1 - (np.sum(np.square(gam - lti_approx.y)) / np.sum(np.square(gam - np.mean(gam)) )) - # if NSE is nan, set to -10e6 - if np.isnan(NSE): - NSE = -10e6 - - - if verbose: - print("initial NSE") - print(NSE) - print("desired NSE") - print(desired_NSE) - - iterations = 0 - - speeds = [10,5,2,1.1,1.05,1.01,1.001] - speed_idx = 0 - leap = speeds[speed_idx] - # the area under the curve is normalized to be one. so rather than basing our desired error off the - # max of the distribution, it might be better to make it a percentage error, one percent or five percent - while (NSE < desired_NSE and iterations < max_iterations): - - og_was_best = True # start each iteration assuming that the original is the best - # search across the C vector - for i in range(C.shape[1]-1,int(-1),int(-1)): # accross the columns # start at the end and come back - #for i in range(int(0),C.shape[1],int(1)): # accross the columns, start at the beginning and go forward - - og_approx = control.ss(A,B,C,0) - og_y = np.ndarray.flatten(control.impulse_response(og_approx,t).y) - og_error = np.sum(np.abs(gam - og_y)) - og_NSE = 1 - (np.sum((gam - og_y)**2) / np.sum((gam - np.mean(gam))**2)) - - Ctwice = np.array(C, copy=True) - Ctwice[0,i] = leap*C[0,i] - twice_approx = control.ss(A,B,Ctwice,0) - twice_y = np.ndarray.flatten(control.impulse_response(twice_approx,t).y) - twice_error = np.sum(np.abs(gam - twice_y)) - twice_NSE = 1 - (np.sum((gam - twice_y)**2) / np.sum((gam - np.mean(gam))**2)) - - Chalf = np.array(C,copy=True) - Chalf[0,i] = (1/leap)*C[0,i] - half_approx = control.ss(A,B,Chalf,0) - half_y = np.ndarray.flatten(control.impulse_response(half_approx,t).y) - half_error = np.sum(np.abs(gam - half_y)) - half_NSE = 1 - (np.sum((gam - half_y)**2) / np.sum((gam - np.mean(gam))**2)) - ''' - Cneg = np.array(C,copy=True) - Cneg[0,i] = -C[0,i] - neg_approx = control.ss(A,B,Cneg,0) - neg_y = np.ndarray.flatten(control.impulse_response(neg_approx,t).y) - neg_error = np.sum(np.abs(gam - neg_y)) - neg_NSE = 1 - (np.sum((gam - neg_y)**2) / np.sum((gam - np.mean(gam))**2)) - ''' - faster = np.array(A,copy=True) - faster[i,i] = A[i,i]*leap # faster decay - if abs(faster[i,i]) < abs(max_pole_speed): - if i > 0: # first reservoir doesn't receive contribution from another reservoir. want to keep B at 1 for scaling - faster[i,i-1] = A[i,i-1]*leap # faster rise - faster_approx = control.ss(faster,B,C,0) - faster_y = np.ndarray.flatten(control.impulse_response(faster_approx,t).y) - faster_error = np.sum(np.abs(gam - faster_y)) - faster_NSE = 1 - (np.sum((gam - faster_y)**2) / np.sum((gam - np.mean(gam))**2)) - else: - faster_NSE = -10e6 # disallowed because the pole is too fast - - slower = np.array(A,copy=True) - slower[i,i] = A[i,i]/leap # slower decay - if abs(slower[i,i]) > abs(min_pole_speed): - if i > 0: - slower[i,i-1] = A[i,i-1]/leap # slower rise - slower_approx = control.ss(slower,B,C,0) - slower_y = np.ndarray.flatten(control.impulse_response(slower_approx,t).y) - slower_error = np.sum(np.abs(gam - slower_y)) - slower_NSE = 1 - (np.sum((gam - slower_y)**2) / np.sum((gam - np.mean(gam))**2)) - else: - slower_NSE = -10e6 # disallowed because the pole is too slow - - #all_errors = [og_error, twice_error, half_error, faster_error, slower_error] - all_NSE = [og_NSE, twice_NSE, half_NSE, faster_NSE, slower_NSE]# , neg_NSE] - - if (twice_NSE >= max(all_NSE) and twice_NSE > og_NSE): - C = Ctwice - if twice_NSE > 1.001*og_NSE: # an appreciable difference - og_was_best = False # did we change something this iteration? - elif (half_NSE >= max(all_NSE) and half_NSE > og_NSE): - C = Chalf - if half_NSE > 1.001*og_NSE: # an appreciable difference - og_was_best = False # did we change something this iteration? - - elif (slower_NSE >= max(all_NSE) and slower_NSE > og_NSE): - A = slower - if slower_NSE > 1.001*og_NSE: # an appreciable difference - og_was_best = False # did we change something this iteration? - elif (faster_NSE >= max(all_NSE) and faster_NSE > og_NSE): - A = faster - if faster_NSE > 1.001*og_NSE: # an appreciable difference - og_was_best = False # did we change something this iteration? - ''' - elif (neg_NSE >= max(all_NSE) and neg_NSE > og_NSE): - C = Cneg - if neg_NSE > 1.001*og_NSE: - og_was_best = False - ''' - - - - NSE = og_NSE - error = og_error - iterations += 1 # this shouldn't be the termination condition unless the resolution is too coarse - # normally the optimization should exit because the leap has become too small - if og_was_best: # the original was the best, so we're going to tighten up the optimization - speed_idx += 1 - if speed_idx > len(speeds)-1: - break # we're done - leap = speeds[speed_idx] - # print the iteration count every ten - # comment out for production - if (iterations % 2 == 0 and verbose): - print("iterations = ", iterations) - print("error = ", error) - print("NSE = ", NSE) - print("leap = ", leap) - - lti_approx = control.ss(A,B,C,0) - y = np.ndarray.flatten(control.impulse_response(og_approx,t).y) - error = np.sum(np.abs(gam - og_y)) - print("LTI_from_gamma final NSE") - print(NSE) - if (verbose): - print("final system\n") - print("A") - print(A) - print("B") - print(B) - print("C") - print(C) - - print("\nfinal error") - print(error) - - # are any of the final eigenvalues outside the bounds specified? - E = np.linalg.eigvals(A) - if (np.any(np.abs(E) > max_pole_speed) or np.any(np.abs(E) < min_pole_speed)): - print("WARNING: final eigenvalues are outside the bounds specified") - - - return {"lti_approx":lti_approx, "lti_approx_output":y, "error":error, "t":t, "gamma_pdf":gam} - - - -# this function takes the system data and the causative topology and returns an LTI system -# if the causative topology isn't already defined, it needs to be created using infer_causative_topology -def lti_system_gen(causative_topology, system_data,independent_columns,dependent_columns,max_iter=250, - swmm=False,bibo_stable = False,max_transition_state_dim=50, max_transforms = 1, early_stopping_threshold = 0.005): - - # cast the columns and indices of causative_topology to strings so sindy can run properly - # We need the tuples to link the columns in system_data to the object names in the swmm model - # so we'll cast these back to tuples once we're done - if swmm: - causative_topology.columns = causative_topology.columns.astype(str) - causative_topology.index = causative_topology.index.astype(str) - - print("causative topology \n") - print(causative_topology.index) - print(causative_topology.columns) - - # do the same for dependent_columns and independent_columns - dependent_columns = [str(col) for col in dependent_columns] - independent_columns = [str(col) for col in independent_columns] - print(dependent_columns) - print(independent_columns) - - - # do the same for the columns of system_data - system_data.columns = system_data.columns.astype(str) - print(system_data.columns) - - - A = pd.DataFrame(index=dependent_columns, columns=dependent_columns) - B = pd.DataFrame(index=dependent_columns, columns=independent_columns) - C = pd.DataFrame(index=dependent_columns,columns=dependent_columns) - C.loc[:,:] = np.diag(np.ones(len(dependent_columns))) # these are the states which are observable - - # copy the corresponding entries from the causative topology into B - for row in B.index: - for col in B.columns: - B.loc[row,col] = causative_topology.loc[row,col] - # and into A - for row in A.index: - for col in A.columns: - A.loc[row,col] = causative_topology.loc[row,col] - - print("A") - print(A) - print("B") - print(B) - print("C") - print(C) - # use transform_only when calling delay_io_train to only train transfomrations for connections marked "d" - # train a MISO model for each output - delay_models = {key: None for key in dependent_columns} - - for row in A.index: - immediate_forcing = [] - delayed_forcing = [] - for col in A.columns: - if col == row: - continue # don't need to include the output state as a forcing variable. it's already included by default - if A[col][row] == "d": - delayed_forcing.append(col) - elif A[col][row] == "i": - immediate_forcing.append(col) - for col in B.columns: - if B[col][row] == "d": - delayed_forcing.append(col) - elif B[col][row] == "i": - immediate_forcing.append(col) - # make total_forcing the union of immediate and delayed forcing - total_forcing = immediate_forcing + delayed_forcing - feature_names = [row] + total_forcing - if (delayed_forcing): - print("training delayed model for ", row, " with forcing ", total_forcing, "\n") - delay_models[row] = delay_io_train(system_data,[row],total_forcing, - transform_only=delayed_forcing, max_transforms=max_transforms, - poly_order=1, max_iter=max_iter,verbose=False,bibo_stable=bibo_stable) - # we'll parse this delayed causation into the matrices A, B, and C later - else: - ####### TODO: incorporate bibo stability constraint into instantaneous fits ######## - print("training immediate model for ", row, " with forcing ", total_forcing, "\n") - delay_models[row] = None - # we can put immediate causation into the matrices A, B, and C now - - if (bibo_stable): # negative autocorrelatoin - # Figure out how many library features there will be - library = ps.PolynomialLibrary(degree=1,include_bias = False, include_interaction=False) - #total_train = pd.concat((response,forcing), axis='columns') - library.fit([ps.AxesArray(feature_names,{"ax_sample":0,"ax_coord":1})]) - n_features = library.n_output_features_ - #print(f"Features ({n_features}):", library.get_feature_names()) - # Set constraints - #n_targets = total_train.shape[1] # not sure what targets means after reading through the pysindy docs - #print("n_targets") - #print(n_targets) - constraint_rhs = 0 - # one row per constraint, one column per coefficient - constraint_lhs = np.zeros((1 , n_features )) - - #print(constraint_rhs) - #print(constraint_lhs) - # constrain the highest order output autocorrelation to be negative - # this indexing is only right for include_interaction=False, include_bias=False, and pure polynomial library - # for more complex libraries, some conditional logic will be needed to grab the right column - constraint_lhs[:,0] = 1 - - model = ps.SINDy( - differentiation_method= ps.FiniteDifference(), - feature_library=ps.PolynomialLibrary(degree=1,include_bias = False, include_interaction=False), - optimizer = ps.STLSQ(threshold=0), - feature_names = feature_names - ) - - else: # unoconstrained - model = ps.SINDy( - differentiation_method= ps.FiniteDifference(order=10,drop_endpoints=True), - feature_library=ps.PolynomialLibrary(degree=1,include_bias = False, include_interaction=False), - optimizer=ps.optimizers.STLSQ(threshold=0,alpha=0), - feature_names = feature_names - ) - if system_data.loc[:,immediate_forcing].empty: # the subsystem is autonomous - instant_fit = model.fit(x = system_data.loc[:,row] ,t = np.arange(0,len(system_data.index),1)) - instant_fit.print(precision=3) - print("Training r2 = ", instant_fit.score(x = system_data.loc[:,row] ,t = np.arange(0,len(system_data.index),1))) - print(instant_fit.coefficients()) - else: # there is some forcing - #instant_fit = model.fit(x = system_data.loc[:,row] ,t = system_data.index.values, u = system_data.loc[:,immediate_forcing]) # sindy can't take datetime indices - instant_fit = model.fit(x = system_data.loc[:,row] ,t = np.arange(0,len(system_data.index),1) , u = system_data.loc[:,immediate_forcing]) - instant_fit.print(precision=3) - print("Training r2 = ", instant_fit.score(x = system_data.loc[:,row] ,t = np.arange(0,len(system_data.index),1), u = system_data.loc[:,immediate_forcing])) - print(instant_fit.coefficients()) - for idx in range(len(feature_names)): - if feature_names[idx] in A.columns: - A.loc[row,feature_names[idx]] = instant_fit.coefficients()[0][idx] - elif feature_names[idx] in B.columns: - B.loc[row,feature_names[idx]] = instant_fit.coefficients()[0][idx] - else: - print("couldn't find a column for ", feature_names[idx]) - #print("updated A") - #print(A) - #print("updated B") - #print(B) - - original_A = A.copy(deep=True) - # now, parse the delay models into the A, B, and C matrices - # the changes will be as follows: - # the A matrix will have matrices of the form [B_gam, A_gam; 0 , C_gam] inserted into it - # where X_gam are the matrices generated by the lti_from_gamma function to represent the delayed causation shape - # the B and C matrices will just have zeros inserted into them to maintain compatible dimensions - # none of these cascades are observable or directly receive input. - for row in original_A.index: - if delay_models[row] is None: - pass - else: # we want the model with the most transformations where the last trnasformation added at least 0.5% to the R2 score - for num_transforms in range(1,max_transforms+1): - if num_transforms == 1: - optimal_number_transforms = num_transforms - elif delay_models[row][num_transforms]['final_model']['error_metrics']['r2'] - delay_models[row][num_transforms-1]['final_model']['error_metrics']['r2'] < early_stopping_threshold: - optimal_number_transforms = num_transforms - 1 - break # improvement is too small to justify additional complexity - else: - optimal_number_transforms = num_transforms # the most recent one was worth it - - transformation_approximations = {transform_key: None for transform_key in delay_models[row][optimal_number_transforms]['shape_factors'].columns} - for transform_key in transformation_approximations.keys(): # which input - for idx in range(1,optimal_number_transforms+1): # which transformation - print("variable = ", transform_key, ", transformation = ", idx) - delay_models[row][optimal_number_transforms]['final_model']['model'].print(precision=5) - shape = delay_models[row][optimal_number_transforms]['shape_factors'].loc[idx,transform_key] - scale = delay_models[row][optimal_number_transforms]['scale_factors'].loc[idx,transform_key] - loc = delay_models[row][optimal_number_transforms]['loc_factors'].loc[idx,transform_key] - ''' - # infer the timestep of system_data from the index - timestep = system_data.index[1] - system_data.index[0] - try: # if the timestep is numeric - pd.to_numeric(timestep) - transformation_approximations[transform_key] = lti_from_gamma(shape,scale,loc,dt=timestep) - - Agam = transformation_approximations[transform_key]['lti_approx'].A / timestep - Bgam = transformation_approximations[transform_key]['lti_approx'].B / timestep - Cgam = transformation_approximations[transform_key]['lti_approx'].C / timestep - except Exception as e: # if the timestep is something like a datetime - print(e)''' - # this will get overwritten if we use more than one transformation per input. i think that's okay. - transformation_approximations[transform_key] = lti_from_gamma(shape,scale,loc,max_state_dim = max_transition_state_dim) - - Agam = transformation_approximations[transform_key]['lti_approx'].A - Bgam = transformation_approximations[transform_key]['lti_approx'].B # only entry is unit impulse at top state - Cgam = transformation_approximations[transform_key]['lti_approx'].C - - tr_string = str("_tr_" + str(idx)) - - # Cgam needs to be scaled by the coefficient the forcing term had in the delay model - #coefficients = {coef_key: None for coef_key in delay_models[row][1]['final_model']['model'].feature_names} - coefficients = {coef_key: None for coef_key in delay_models[row][optimal_number_transforms]['final_model']['model'].feature_names} - for coef_key in coefficients.keys(): - coef_index = delay_models[row][optimal_number_transforms]['final_model']['model'].feature_names.index(coef_key) - coefficients[coef_key] = delay_models[row][optimal_number_transforms]['final_model']['model'].coefficients()[0][coef_index] - #if "_tr_1" in coef_key and coef_key.replace("_tr_1","") == transform_key.replace("_tr_1",""): - if tr_string in coef_key and coef_key.replace(tr_string,"") == transform_key.replace(tr_string,""): - ''' - try: - pd.to_numeric(timestep,errors='raise') - Cgam = Cgam * coefficients[coef_key] / timestep - except Exception as e: - print(e) - Cgam = Cgam * coefficients[coef_key] - ''' - - Cgam = Cgam * coefficients[coef_key] # scaling - else: # these are the immediate effects, insert them now - if coef_key in A.columns: - A.loc[row,coef_key] = coefficients[coef_key] - elif coef_key in B.columns: - B.loc[row,coef_key] = coefficients[coef_key] - - - Agam_index = [] - for agam_idx in range(Agam.shape[0]): - #Agam_index.append(transform_key.replace("_tr_1","") + "->" + row + "_" + str(idx)) - Agam_index.append(transform_key.replace(tr_string,"") + "->" + row + tr_string + "_" + str(agam_idx)) - Agam = pd.DataFrame(Agam, index = Agam_index, columns = Agam_index) - Bgam = pd.DataFrame(Bgam, index = Agam_index, columns = [transform_key.replace(tr_string,"")]) - Cgam = pd.DataFrame(Cgam, index = [row], columns = Agam_index) - #print("Agam") - #print(Agam) - #print("Bgam") - #print(Bgam) - #print("Cgam") - #print(Cgam) - # insert these into the A, B, and C matrices - # for Agam, the insertion row is immediately after the source (key) - # the insertion column is also immediately after the source (key) - - ### everything below this point is garbage. not performing at all as desired at the moment - - - # first need to create space for the new rows and columns - # create before_index and after_index variables, which record the parts of the index of A that occur before and after row - before_index = [] - #after_index = [] - #if transform_key.replace("_tr_1","") not in A.index: # it's one of the forcing terms. put it in at the beginning - if transform_key.replace(tr_string,"") not in A.index: # it's one of the forcing terms. put it in at the beginning - after_index = list(A.index) # it's a forcing variable, so we don't want it in the newA index - else: # it is a state variable - #before_index = list(A.index[:A.index.get_loc(transform_key.replace("_tr_1",""))]) - before_index = list(A.index[:A.index.get_loc(transform_key.replace(tr_string,""))]) - - #after_index = list(A.index[A.index.get_loc(transform_key.replace("_tr_1",""))+1:]) - after_index = list(A.index[A.index.get_loc(transform_key.replace(tr_string,""))+1:]) - - ''' - for idx in A.index: - if idx == key.replace("_tr_1",""): - before_index.append(idx) # if it's a state variable, we want it in the newA index - break - else: - before_index.append(idx) - for idx in range(A.index.get_loc(key.replace("_tr_1",""))+1,len(A.index)): - after_index.append(A.index[idx]) - ''' - #if transform_key.replace("_tr_1","") in A.index: # the transform key refers to a state (x) - if transform_key.replace(tr_string,"") in A.index: - #states = before_index + [transform_key.replace("_tr_1","")] + Agam_index + after_index # state dim expands by the number of rows in Agam - states = before_index + [transform_key.replace(tr_string,"")] + Agam_index + after_index # state dim expands by the number of rows in Agam - # include the current transform key in A because it's a state variable - #elif transform_key.replace("_tr_1","") in B.columns: # the transform key refers to a control input (u) - elif transform_key.replace(tr_string,"") in B.columns: # the transform key refers to a control input (u) - states = before_index + Agam_index + after_index # state dim expands by the number of rows in Agam - # don't include the current transform key in A because it's a control input, not a state variable - - newA = pd.DataFrame(index=states, columns = states) - newB = pd.DataFrame(index = states, columns = B.columns) # input dim remains consistent (columns of B) - newC = pd.DataFrame(index = C.index, columns = states) # output dim remains consistent (rows of C) - - # fill in newA with the corresponding entries from A - for idx in newA.index: - for col in newA.columns: - if idx in A.index and col in A.columns: # if it's in the original A matrix, copy it over - newA.loc[idx,col] = A.loc[idx,col] - if idx in Agam.index and col in Agam.columns: # if it's in Agam, copy it over - newA.loc[idx,col] = Agam.loc[idx,col] - if idx in Bgam.index and col in Bgam.columns: # the input to the cascade is a state - newA.loc[idx,col] = Bgam.loc[idx,col] - - - for idx in newB.index: - for col in newB.columns: - if idx in B.index and col in B.columns: # if it's in the original B matrix, copy it over - newB.loc[idx,col] = B.loc[idx,col] - if idx in Bgam.index and col in Bgam.columns: # the input to the cascade is a forcing term - newB.loc[idx,col] = Bgam.loc[idx,col] - - for idx in newC.index: - for col in newC.columns: - if idx in C.index and col in C.columns: # if it's in the original C matrix, copy it over - newC.loc[idx,col] = C.loc[idx,col] - if idx in Cgam.index and col in Cgam.columns: # outputs from the cascades - newA.loc[idx,col] = Cgam.loc[idx,col] - - #print("newA") - #print(newA.to_string()) - #print("newB") - #print(newB.to_string()) - #print("newC") - #print(newC.to_string()) - - # copy over - A = newA.copy(deep=True) - B = newB.copy(deep=True) - C = newC.copy(deep=True) - - - A.replace("n",0.0,inplace=True) - B.replace("n",0.0,inplace=True) - C.replace("n",0.0,inplace=True) - - if swmm: - pass - ############# - # TODO: cast strings back to tuples in the indices and columns - ############# - # cast the index and columns of causative_topology to tuples. they'll be of the form "(X,Y)" - - # do the same for dependent_columns and independent_columns - - # do the same for the columns of system_data - - - - - A.fillna(0.0,inplace=True) - B.fillna(0.0,inplace=True) - C.fillna(0.0,inplace=True) - - # if bibo_stable is specified and A not hurwitz, make A hurwitz by defining A' = A - I*max(real(eig(A))) - # this will gaurantee stability (max eigenvalue will have real part < 0) - if bibo_stable: - orig_eigs, _ = np.linalg.eig(A) - if any(np.real(orig_eigs) > 0): - print("stabilizing unstable plant by subtracting I*max(real(eig)) from A") - epsilon = 10e-4 - A_stab = A - np.eye(len(A))*(1+epsilon)*max(np.real(orig_eigs)) # add factor of (1+epsilon) for stability, not marginal stabilty - stab_eigs, _ = np.linalg.eig(A_stab) - A = A_stab.copy(deep=True) - - # sindy will scale the coefficients according to the timestep if the index is numeric - # so the whole system needs to be scaled by the timestep if its numeric - try: - pd.to_numeric(system_data.index,errors='raise') # can the index be converted to a numeric type? - dt = system_data.index.values[1] - system_data.index.values[0] - A = A / dt - B = B / dt - C = C # what we observe doesn't need to be adjusted, just the dynamics - print("system response data index converted to numeric type. dt = ") - print(dt) - except Exception as e: - print(e) - dt = None - - # cast all of A, B, and C to type float (integers cause issues with LQR / LQE calculations) - A = A.astype(float) - B = B.astype(float) - C = C.astype(float) - - lti_sys = control.ss(A,B,C,0,inputs=B.columns,outputs=C.index,states=A.columns) - - - # returning the matrices too because control.ss strips the labels from the pandas dataframes and stores them as numpy matrices - return {"system":lti_sys,"A":A,"B":B,"C":C} - - - - - -# this function takes in the system data, -# which columns are dependent and which are independent, -# as well as an optional constraint on the topology of the digraph -# we will return a digraph (not multidigraph as there are no parallel edges) as defined in https://networkx.org/documentation/stable/reference/classes/digraph.html -# we'll assume there are always self-loops (the derivative always depends on the current value of the variable) -# this will also be returned as an adjacency matrix -# this doesn't go all the way to turning the data into an LTI system. that will be another function that uses this one -def infer_causative_topology(system_data, dependent_columns, independent_columns, - graph_type='Weak-Conn',verbose=False,max_iter = 250,swmm=False, - method='granger', derivative=False): - - if swmm: - # do the same for dependent_columns and independent_columns - dependent_columns = [str(col) for col in dependent_columns] - independent_columns = [str(col) for col in independent_columns] - print(dependent_columns) - print(independent_columns) - - - # do the same for the columns of system_data - system_data.columns = system_data.columns.astype(str) - print(system_data.columns) - - if method == 'granger': # granger causality - from statsmodels.tsa.stattools import grangercausalitytests - causative_topo = pd.DataFrame(index=dependent_columns,columns=system_data.columns).fillna('n') - total_graph = pd.DataFrame(index=dependent_columns,columns=system_data.columns).fillna(1.0) - - print(causative_topo) - - max_p = pd.DataFrame(index=dependent_columns,columns=system_data.columns).fillna(-1.0) - min_p = pd.DataFrame(index=dependent_columns,columns=system_data.columns).fillna(2.0) - median_p = pd.DataFrame(index=dependent_columns,columns=system_data.columns).fillna(2.0) - three_quarters_p = pd.DataFrame(index=dependent_columns,columns=system_data.columns).fillna(2.0) - one_quarter_p = pd.DataFrame(index=dependent_columns,columns=system_data.columns).fillna(2.0) - min_p_lag = pd.DataFrame(index=dependent_columns,columns=system_data.columns).fillna(-1) - max_p_lag = pd.DataFrame(index=dependent_columns,columns=system_data.columns).fillna(-1) - max_p_f = pd.DataFrame(index=dependent_columns,columns=system_data.columns).fillna(-1.0) - min_p_f = pd.DataFrame(index=dependent_columns,columns=system_data.columns).fillna(-1.0) - median_f = pd.DataFrame(index=dependent_columns,columns=system_data.columns).fillna(-1.0) - three_quarters_f = pd.DataFrame(index=dependent_columns,columns=system_data.columns).fillna(-1.0) - one_quarter_f = pd.DataFrame(index=dependent_columns,columns=system_data.columns).fillna(-1.0) - - - # first column in df is the output (granger caused by other) - # second column is the proposed forcer - for dep_col in dependent_columns: # for each column which is out - for other_col in system_data.columns: # for every other variable (input) - if other_col == dep_col: - continue # we're already accounting for autocorrelatoin in every fit - print("check if ", other_col, " granger causes ", dep_col) - #print(system_data[[dep_col,other_col]]) - try: - gc_res = grangercausalitytests(system_data[[dep_col,other_col]],maxlag=25,verbose=False) - except Exception as e: - print(e) - continue - # iterate through the dictionary and compute the maximum and minimum p values for the F test - p_values = [] - f_values = [] - for key in gc_res.keys(): - f_test_p_value = gc_res[key][0]['ssr_ftest'][1] - p_values.append(f_test_p_value) - f_values.append(gc_res[key][0]['ssr_ftest'][0]) - if f_test_p_value > max_p.loc[dep_col,other_col]: - max_p.loc[dep_col,other_col] = f_test_p_value - max_p_f.loc[dep_col,other_col] = gc_res[key][0]['ssr_ftest'][0] - max_p_lag.loc[dep_col,other_col] = key - - if f_test_p_value < min_p.loc[dep_col,other_col]: - min_p.loc[dep_col,other_col] = f_test_p_value - min_p_f.loc[dep_col,other_col] = gc_res[key][0]['ssr_ftest'][0] - min_p_lag.loc[dep_col,other_col] = key - - median_p.loc[dep_col,other_col] = np.median(p_values) - median_f.loc[dep_col,other_col] = np.median(f_values) - three_quarters_p.loc[dep_col,other_col] = np.quantile(p_values,0.75) - three_quarters_f.loc[dep_col,other_col] = np.quantile(f_values,0.75) - one_quarter_p.loc[dep_col,other_col] = np.quantile(p_values,0.25) - one_quarter_f.loc[dep_col,other_col] = np.quantile(f_values,0.25) - - print("max p values") - print(max_p) - print("f values corresponding to max p") - print(max_p_f) - print("max p lag") - print(max_p_lag) - print("min p values") - print(min_p) - print("f values corresponding to min p") - print(min_p_f) - print("min p lag") - print(min_p_lag) - print("median p values") - print(median_p) - print("median f values") - print(median_f) - - print("now determine causative topology based on connectivity constraint") - # start with the maximum p values, taking the significant links, then move down through the quantiles - # if the graph is not connected, we'll move down to the next quantile - # keep going until you satisfy the connectivity criteria - if graph_type == 'Weak-Conn': - # locate the smallest value of p in max_p which corresponds to an "n" in causative topo - # this will be the first link we add - ''' - i = 0 - while(i < 10e3): - i += 1 - min_p_value = 2.0 - min_p_row = None - min_p_col = None - for row in causative_topo.index: - for col in causative_topo.columns: - if max_p.loc[row,col] < 0: - continue # not valid - if max_p.loc[row,col] < min_p_value and causative_topo.loc[row,col] == 'n': - min_p_value = max_p.loc[row,col] - min_p_row = row - min_p_col = col - # if equal - elif max_p.loc[row,col] == min_p_value and causative_topo.loc[row,col] == 'n': - if min_p_value < 0.05: - print("tie in significant p") - # take the one with the higher f value - if max_p_f.loc[row,col] > max_p_f.loc[min_p_row,min_p_col]: - min_p_value = max_p.loc[row,col] - min_p_row = row - min_p_col = col - - if min_p_value < 0.05: - causative_topo.loc[min_p_row,min_p_col] = 'd' - total_graph.loc[min_p_row,min_p_col] = min_p_value - print("added link from ", min_p_col, " to ", min_p_row, " with p = ", min_p_value) - print(causative_topo) - print(nx.is_weakly_connected(nx.from_pandas_adjacency(total_graph.replace(1.0,0),create_using=nx.DiGraph))) - if nx.is_weakly_connected(nx.from_pandas_adjacency(total_graph.replace(1.0,0),create_using=nx.DiGraph)): - print("graph is connected") - print(causative_topo) - print(total_graph) - return causative_topo, total_graph - else: - print("no significant links found") - break - print("done adding from max_p, now adding from 3/4 p") - i = 0 - while(i < 10e3): - i += 1 - min_p_value = 2.0 - min_p_row = None - min_p_col = None - for row in causative_topo.index: - for col in causative_topo.columns: - if three_quarters_p.loc[row,col] < 0: - continue # not valid - if three_quarters_p.loc[row,col] < min_p_value and causative_topo.loc[row,col] == 'n': - min_p_value = three_quarters_p.loc[row,col] - min_p_row = row - min_p_col = col - elif three_quarters_p.loc[row,col] == min_p_value and causative_topo.loc[row,col] == 'n': - if min_p_value < 0.05: - print("tie in significant p") - # take the one with the higher f value - if three_quarters_f.loc[row,col] > three_quarters_f.loc[min_p_row,min_p_col]: - min_p_value = three_quarters_p.loc[row,col] - min_p_row = row - min_p_col = col - - if min_p_value < 0.05: - causative_topo.loc[min_p_row,min_p_col] = 'd' - total_graph.loc[min_p_row,min_p_col] = min_p_value - print("added link from ", min_p_col, " to ", min_p_row, " with p = ", min_p_value) - print(causative_topo) - print(nx.is_weakly_connected(nx.from_pandas_adjacency(total_graph.replace(1.0,0),create_using=nx.DiGraph))) - if nx.is_weakly_connected(nx.from_pandas_adjacency(total_graph.replace(1.0,0),create_using=nx.DiGraph)): - print("graph is connected") - print(causative_topo) - print(total_graph) - return causative_topo, total_graph - else: - print("no significant links found") - break - print("done adding from three_quarters_p, now adding from median p") - # move to the median - i = 0 - while(i < 10e3): - i += 1 - min_p_value = 2.0 - min_p_row = None - min_p_col = None - for row in causative_topo.index: - for col in causative_topo.columns: - if median_p.loc[row,col] < 0: - continue - if median_p.loc[row,col] < min_p_value and causative_topo.loc[row,col] == 'n': - min_p_value = median_p.loc[row,col] - min_p_row = row - min_p_col = col - elif median_p.loc[row,col] == min_p_value and causative_topo.loc[row,col] == 'n': - if min_p_value < 0.05: - print("tie in significant p") - # take the one with the higher f value - if median_f.loc[row,col] > median_f.loc[min_p_row,min_p_col]: - min_p_value = median_p.loc[row,col] - min_p_row = row - min_p_col = col - - if min_p_value < 0.05: - causative_topo.loc[min_p_row,min_p_col] = 'd' - total_graph.loc[min_p_row,min_p_col] = min_p_value - print("added link from ", min_p_col, " to ", min_p_row, " with p = ", min_p_value) - print(causative_topo) - print(nx.is_weakly_connected(nx.from_pandas_adjacency(total_graph.replace(1.0,0),create_using=nx.DiGraph))) - if nx.is_weakly_connected(nx.from_pandas_adjacency(total_graph.replace(1.0,0),create_using=nx.DiGraph)): - print("graph is connected") - print(causative_topo) - print(total_graph) - return causative_topo, total_graph - else: - print("no significant links found") - break - print("done adding from median p, now adding from min p") - i = 0 - while(i < 10e3): - i += 1 - min_p_value = 2.0 - min_p_row = None - min_p_col = None - for row in causative_topo.index: - for col in causative_topo.columns: - if one_quarter_p.loc[row,col] < 0: - continue - if one_quarter_p.loc[row,col] < min_p_value and causative_topo.loc[row,col] == 'n': - min_p_value = one_quarter_p.loc[row,col] - min_p_row = row - min_p_col = col - elif one_quarter_p.loc[row,col] == min_p_value and causative_topo.loc[row,col] == 'n': - if min_p_value < 0.05: - print("tie in significant p") - # take the one with the higher f value - if one_quarter_f.loc[row,col] > one_quarter_f.loc[min_p_row,min_p_col]: - min_p_value = one_quarter_p.loc[row,col] - min_p_row = row - min_p_col = col - - if min_p_value < 0.05: - causative_topo.loc[min_p_row,min_p_col] = 'd' - total_graph.loc[min_p_row,min_p_col] = min_p_value - print("added link from ", min_p_col, " to ", min_p_row, " with p = ", min_p_value) - print(causative_topo) - print(nx.is_weakly_connected(nx.from_pandas_adjacency(total_graph.replace(1.0,0),create_using=nx.DiGraph))) - if nx.is_weakly_connected(nx.from_pandas_adjacency(total_graph.replace(1.0,0),create_using=nx.DiGraph)): - print("graph is connected") - print(causative_topo) - print(total_graph) - return causative_topo, total_graph - else: - print("no significant links found") - break - print("done adding from median p, now adding from min p") - ''' - # move to the min - i = 0 - while(i < 10e3): - i += 1 - min_p_value = 2.0 - min_p_row = None - min_p_col = None - for row in causative_topo.index: - for col in causative_topo.columns: - if min_p.loc[row,col] < 0: - continue - if min_p.loc[row,col] < min_p_value and causative_topo.loc[row,col] == 'n': - min_p_value = min_p.loc[row,col] - min_p_row = row - min_p_col = col - elif min_p.loc[row,col] == min_p_value and causative_topo.loc[row,col] == 'n': - if min_p_value < 0.05: - print("tie in significant p") - # take the one with the higher f value - if min_p_f.loc[row,col] > min_p_f.loc[min_p_row,min_p_col]: - min_p_value = min_p.loc[row,col] - min_p_row = row - min_p_col = col - - if min_p_value < 0.05 or True: - causative_topo.loc[min_p_row,min_p_col] = 'd' - total_graph.loc[min_p_row,min_p_col] = min_p_value - print("added link from ", min_p_col, " to ", min_p_row, " with p = ", min_p_value) - print(causative_topo) - print(nx.is_weakly_connected(nx.from_pandas_adjacency(total_graph.replace(1.0,0),create_using=nx.DiGraph))) - if nx.is_weakly_connected(nx.from_pandas_adjacency(total_graph.replace(1.0,0),create_using=nx.DiGraph)): - print("graph is connected") - print(causative_topo) - print(total_graph) - return causative_topo, total_graph - else: - print("no significant links found") - break - print("done adding from min p. if graph not connected now, it won't be") - print(causative_topo) - print(total_graph) - return causative_topo, total_graph - - - elif method == 'ccm': # convergent cross mapping per sugihara 2012 - - correlations = pd.DataFrame(index=dependent_columns,columns=system_data.columns).fillna(0.0) - p_values = pd.DataFrame(index=dependent_columns,columns=system_data.columns).fillna(1.0) - best_taus = pd.DataFrame(index=dependent_columns,columns=system_data.columns) - best_Es = pd.DataFrame(index=dependent_columns,columns=system_data.columns) - - from causal_ccm.causal_ccm import ccm # move to initial imports if this ends up working - - for dep_col in dependent_columns: # for each column which is out - if derivative: - response = np.array(system_data[dep_col].diff().values[1:]) - else: - response = np.array(system_data[dep_col].values) - - for other_col in system_data.columns: # for every other variable (input) - plt.close('all') - if other_col == dep_col: - continue # we're already accounting for autocorrelatoin in every fit - print("check if ", other_col, " drives ", dep_col) - if derivative: - forcing = np.array(system_data[other_col].values[:-1]) - else: - forcing = np.array(system_data[other_col].values) - - # start with tau_options to be between 1 and 25 timesteps - tau_options = np.arange(1,2)#1) - E_options = np.arange(1,3) # number of embedding dimensions - best_p_value = 1.0 # null hypothesis is that there is no causality - best_tau = -1 # then we'll know if no lags had good results - for tau in tau_options: - for E in E_options: - cross_map = ccm(forcing,response,tau=tau,E=E,L=len(response)) - correlation, p_value = cross_map.causality() - if p_value < best_p_value: - best_p_value = p_value - best_correlation = correlation - best_tau = tau - best_E = E - print("tau = ", tau, "E = ",E," | p = ", p_value, " | corr = ", correlation) - #cross_map.visualize_cross_mapping() - #cross_map.plot_ccm_correls() - if best_tau > -1: - cross_map = ccm(forcing,response,best_tau,best_E) - ''' - if best_tau > 0: - cross_map.visualize_cross_mapping() - cross_map.plot_ccm_correls() - ''' - correlation, p_value = cross_map.causality() - correlations.loc[dep_col,other_col] = correlation - p_values.loc[dep_col,other_col] = p_value - if p_value == 0: # if the p value is exactly zero, make it the minimum float value - p_values.loc[dep_col,other_col] = sys.float_info.min - best_taus.loc[dep_col,other_col] = best_tau - best_Es.loc[dep_col,other_col] = best_E - ''' - lengths = np.linspace(250, len(response), 100,dtype='int') - corr_L = lengths*0.0 - for length_idx in range(len(lengths)): - trunc_forcing = forcing[:lengths[length_idx]] - trunc_response = response[:lengths[length_idx]] - cross_map = ccm(trunc_forcing,trunc_response,tau=best_tau,E=best_E) - correlation, p_value = cross_map.causality() - corr_L[length_idx] = correlation - - - plt.plot(corr_L) - plt.ylabel("correlation") - plt.show(block=True) - ''' - elif best_tau == -1: - print("no good lags found for ", dep_col, " and ", other_col) - correlations.loc[dep_col,other_col] = 0.0 - p_values.loc[dep_col,other_col] = 1.0 - best_taus.loc[dep_col,other_col] = -1 - best_Es.loc[dep_col,other_col] = -1 - - print(correlations) - print(p_values) - print(best_taus) - print(best_Es) - print("done") - causative_topo = pd.DataFrame(index=dependent_columns,columns=system_data.columns).fillna('n') - total_graph = pd.DataFrame(index=dependent_columns,columns=system_data.columns).fillna(1.0) - i = 0 - while(i < 10e3): - i += 1 - min_p_value = 2.0 - min_p_corr = 0.0 - min_p_row = None - min_p_col = None - for row in causative_topo.index: - for col in causative_topo.columns: - if p_values.loc[row,col] < 0: - continue - if p_values.loc[row,col] < min_p_value and causative_topo.loc[row,col] == 'n': - min_p_value = p_values.loc[row,col] - min_p_corr = correlations.loc[row,col] - min_p_row = row - min_p_col = col - # if two p values are tied, pick the one with the higher correlation - elif p_values.loc[row,col] == min_p_value and causative_topo.loc[row,col] == 'n' and correlations.loc[row,col] > min_p_corr: - min_p_value = p_values.loc[row,col] - min_p_corr = correlations.loc[row,col] - min_p_row = row - min_p_col = col - if min_p_value < 0.05: - causative_topo.loc[min_p_row,min_p_col] = 'd' - total_graph.loc[min_p_row,min_p_col] = min_p_value - print("added link from ", min_p_col, " to ", min_p_row, " with p = ", min_p_value) - print(causative_topo) - print(total_graph.replace(1.0,0)) - print(nx.is_weakly_connected(nx.from_pandas_adjacency(total_graph.replace(1.0,0),create_using=nx.DiGraph))) - if nx.is_weakly_connected(nx.from_pandas_adjacency(total_graph.replace(1.0,0),create_using=nx.DiGraph)): - print("graph is connected") - break - else: - print("no significant links found") - break - - print(causative_topo) - print(total_graph) - return causative_topo, total_graph - - elif method == 'transfer-entropy': - - transfer_entropies = pd.DataFrame(index=dependent_columns,columns=system_data.columns).fillna(0.0) - - from PyIF import te_compute as te - - for dep_col in dependent_columns: # for each column which is out - if derivative: - response = np.array(system_data[dep_col].diff().values[1:]) - else: - response = np.array(system_data[dep_col].values) - - for other_col in system_data.columns: # for every other variable (input) - plt.close('all') - if other_col == dep_col: - continue # we're already accounting for autocorrelatoin in every fit - print("check if ", other_col, " drives ", dep_col) - if derivative: - forcing = np.array(system_data[other_col].values[:-1]) - else: - forcing = np.array(system_data[other_col].values) - - - k_options = np.arange(1,11) # number of neighbors used in KD-tree queries - E_options = np.arange(1,11) # number of embedding dimensions (delay) - best_TE = -1.0 # best transfer entropy so far - for k in k_options: - for E in E_options: - TE = te.te_compute(forcing,response,k,E) # "information transfer from X to Y" - if TE > best_TE: - best_TE = TE - best_k = k - best_E = E - print("k (# neighbors) = ", k, "E (embedding dim) = ",E, " | Transfer Entropy = ", TE) - transfer_entropies.loc[dep_col,other_col] = best_TE - - print("transfer entropies") - print(transfer_entropies) - - causative_topo = pd.DataFrame(index=dependent_columns,columns=system_data.columns).fillna('n') - total_graph = pd.DataFrame(index=dependent_columns,columns=system_data.columns).fillna(0.0) - i = 0 - while(i < 10e3): - i += 1 - max_te = 0.0 - max_te_row = None - max_te_col = None - for row in causative_topo.index: - for col in causative_topo.columns: - if transfer_entropies.loc[row,col] > max_te and causative_topo.loc[row,col] == 'n': - max_te = transfer_entropies.loc[row,col] - max_te_row = row - max_te_col = col - - causative_topo.loc[max_te_row,max_te_col] = 'd' - total_graph.loc[max_te_row,max_te_col] = max_te - print("added link from ", max_te_col, " to ", max_te_row, " with p = ", max_te) - print(causative_topo) - - print(nx.is_weakly_connected(nx.from_pandas_adjacency(total_graph,create_using=nx.DiGraph))) - if nx.is_weakly_connected(nx.from_pandas_adjacency(total_graph,create_using=nx.DiGraph)): - print("graph is connected") - break - - print(causative_topo) - print(total_graph) - return causative_topo, total_graph - - elif method == 'modpods': - # first, identify any immediate causal relationships (no delay) - # only using linear models for the sake of speed. - immediate_impact_strength = pd.DataFrame(index=system_data.columns,columns=system_data.columns).fillna(0.0) - # read as: row variable is affected by column variable - # that way we can read each row (kind of) as a linear differential equation (not exactly, because they're all trained separately) - for dep_col in dependent_columns: # for each column which is out - response = np.array(system_data[dep_col].values) - for other_col in system_data.columns: # for every other variable (input) - if other_col == dep_col: - continue # we're already accounting for autocorrelatoin in every fit - - print("fitting ", dep_col, " to ", other_col) - forcing = np.array(system_data[other_col].values) - - model = ps.SINDy( - differentiation_method= ps.FiniteDifference(), - feature_library=ps.PolynomialLibrary(degree=1,include_bias = False), - optimizer = ps.STLSQ(threshold=0), - feature_names = [str(dep_col),str(other_col)] - ) - - # windup latent states (if your windup is too long, this will error) - model.fit(response, u = forcing) - # training data score - immediate_impact_strength.loc[dep_col,other_col] = model.score(response, u = forcing) - if verbose: - model.print(precision=5) - print(model.score(response, u = forcing)) - - # set the entries in immediate_impact_strength to 0 if they explain less than X% of the variatnce - immediate_impact_strength[immediate_impact_strength < 1/(len(dependent_columns))] = 0.0 - print(immediate_impact_strength) - - # is system already weakly connected? - # if not, we'll need to add edges to make it weakly connected - print("immediate impact already weakly connected?") - print(nx.is_weakly_connected(nx.from_pandas_adjacency(immediate_impact_strength,create_using=nx.DiGraph))) - - # if graph_type == "Weak-Conn" - find the best weakly connected graph - the undirected graph can be fully traversed - # this is a weak constraint. it's essentailly saying all the data belong to the same system and none of it can be completely isolated - # every DAG is weakly connected, but not every weakly connected graph is a DAG (ex: node has no in-edges and an out-edge into a three node cycle) - # "Weak-Conn" is the default value - - # if graph_type == "Strong-Conn" - find the best strongly connected graph - the directed graph can be fully traversed - # this is a stronger constraint. it means that every variable is affected by every other variable. every strongly connected graph is weakly connected - - # could add unilaterally connected graphs - - # if verbose, plot the network after immediate impacts are accounted for - if verbose: - edges = immediate_impact_strength.stack().rename_axis(['source', 'target']).rename('weight').reset_index().query('(source != target) & (weight > 0.0)') - - G = nx.from_pandas_edgelist(edges, source='source', target='target', edge_attr='weight', create_using=nx.DiGraph) - try: - pos = nx.planr_layout(G) - except: - pos = nx.kamada_kawai_layout(G) - - nx.draw_networkx_nodes(G, pos, node_size=100) - nx.draw_networkx_labels(G, pos, font_size=10, font_family='sans-serif') - edges = G.edges() - weights = [G[u][v]['weight'] for u, v in edges] - nx.draw_networkx_edges(G, pos, edgelist=edges, width=weights) - plt.axis('off') - plt.show(block=False) - plt.pause(10) - plt.close('all') - - - # then, test every pair of variables for a causal relationship using delay_io_train. record the r2 score achieved with a siso model - delayed_impact_strength = pd.DataFrame(index=system_data.columns,columns=system_data.columns).fillna(0.0) - # this is read the same way as immediate_impact_strength - - for dep_col in dependent_columns: # for each column which is not forcing - - for other_col in system_data.columns: # for every other variable (including forcing) - if other_col == dep_col: - continue # we're already accounting for autocorrelatoin in every fit - - if verbose: - print("fitting ", dep_col, " to ", other_col) - - subset = system_data[[dep_col,other_col]] - # max iterations is very low here because we're not trying to create an accurate model, just trying to see what affects what - # creating the accurate model is a later task for a different function - # it would be wasteful to spend 100 iterations on each pair of variables - # up the iterations to 10 or so for production. 1 is jsut for development - results = delay_io_train(subset, [dep_col], [other_col], windup_timesteps=0,init_transforms=1, max_transforms=1, max_iter=max_iter, poly_order=1, - transform_dependent=False, verbose=False, extra_verbose=False, - include_bias=False, include_interaction=False, bibo_stable = False) - - delayed_impact_strength.loc[dep_col,other_col] = results[1]['final_model']['error_metrics']['r2'] - - if verbose: - print("R2 score:", results[1]['final_model']['error_metrics']['r2']) - - # iteratively add edges from delayed_impact_strength until the total graph is weakly connected - causative_topo = pd.DataFrame(index=dependent_columns,columns=system_data.columns).fillna('n') - # wherever there is a nonzero entry in immediate_impact_strength, put an "i" in causative_topo - causative_topo[immediate_impact_strength > 0] = "i" - - total_graph = immediate_impact_strength.copy(deep=True) - weakest_row = 0 - - while not nx.is_weakly_connected(nx.from_pandas_adjacency(total_graph,create_using=nx.DiGraph)) and weakest_row < 0.5: - # find the edge with the highest r2 score - max_r2 = delayed_impact_strength.max().max() - max_r2_row = delayed_impact_strength.max(axis='columns').idxmax() - max_r2_col = delayed_impact_strength.max(axis='index').idxmax() - print("\n") - print("max_r2_row", max_r2_row) - print("max_r2_col", max_r2_col) - print("max_r2", max_r2) - print("already exists path from row to col?") - print(nx.has_path(nx.from_pandas_adjacency(total_graph,create_using=nx.DiGraph),max_r2_row,max_r2_col)) - if nx.has_path(nx.from_pandas_adjacency(total_graph,create_using=nx.DiGraph),max_r2_row,max_r2_col): - print("shortest path from row to col") - print(nx.shortest_path(nx.from_pandas_adjacency(total_graph,create_using=nx.DiGraph),max_r2_row,max_r2_col)) - print("shortest path length from row to col") - print(len(nx.shortest_path(nx.from_pandas_adjacency(total_graph,create_using=nx.DiGraph),max_r2_row,max_r2_col))) - shortest_path = len(nx.shortest_path(nx.from_pandas_adjacency(total_graph,create_using=nx.DiGraph),max_r2_row,max_r2_col)) - else: - shortest_path = 0 # no path exists, so the shortest path is 0 - - # add that edge to the total graph if it's r2 score is more than twice the corresponding entry in immediate_impact_strength - # and there is not already a path from the row to the column in the total graph - # constraint 1 is to not include representation of delay when it's not necessary, because it's expensive - # constarint 2 is to not "leapfrog" intervening states when there is some chain of instantaneously related states that allow that causality to flow - if (max_r2 > 2*immediate_impact_strength.loc[max_r2_row,max_r2_col] - and (shortest_path < 3 ) ): - total_graph.loc[max_r2_row,max_r2_col] = max_r2 - causative_topo.loc[max_r2_row,max_r2_col] = "d" - # remove that edge from delayed_impact_strength - delayed_impact_strength.loc[max_r2_row,max_r2_col] = 0.0 - - # make weakest_row the sum of the row of total_graph with the lowest sum - weakest_row = total_graph.loc[dependent_columns,:].sum(axis='columns').min() - - print("total graph") - print(total_graph) - print("delayed impact strength") - print(delayed_impact_strength) - print("\n") - - print("total graph is now weakly connected") - if verbose: - print(total_graph) - print("causative topo") - print(causative_topo) - edges = total_graph.stack().rename_axis(['source', 'target']).rename('weight').reset_index().query('(source != target) & (weight > 0.0)') - - G = nx.from_pandas_edgelist(edges, source='source', target='target', edge_attr='weight', create_using=nx.DiGraph) - try: - pos = nx.planr_layout(G) - except: - pos = nx.kamada_kawai_layout(G) - - nx.draw_networkx_nodes(G, pos, node_size=100) - nx.draw_networkx_labels(G, pos, font_size=10, font_family='sans-serif') - edges = G.edges() - weights = [G[u][v]['weight'] for u, v in edges] - nx.draw_networkx_edges(G, pos, edgelist=edges, width=weights) - plt.axis('off') - plt.show(block=False) - plt.pause(10) - plt.close('all') - - # return an adjacency matrix with "i" for immediate, "d" for delayed, and "n" for no causal relationship - # use "d" if there is strong immediate and delayed causation. immediate causation is always cheap to include, so it'll be in any delayed causation model - - return causative_topo, total_graph - - - -def topo_from_pystorms(pystorms_scenario): - - # if any are 3-tuples, chop them down to 2-tuples - pystorms_scenario.config['states'] = [t[:-1] if len(t) == 3 else t for t in pystorms_scenario.config['states']] - - A = pd.DataFrame(index = pystorms_scenario.config['states'], - columns = pystorms_scenario.config['states']) - B = pd.DataFrame(index = pystorms_scenario.config['states'], - columns = pystorms_scenario.config['action_space']) - - #print("A") - #print(A) - #print("B") - #print(B) - - - # use pyswmm to iterate through the network - with pyswmm.Simulation(pystorms_scenario.config['swmm_input']) as sim: - # start at each subcatchment and iterate down to the outfall - # this should work even in the case of multiple outfalls - # this should capture all the causation, because ultimately everything is precip driven - - # so i can view these while debugging - Subcatchments = pyswmm.Subcatchments(sim) - Nodes = pyswmm.Nodes(sim) - Links = pyswmm.Links(sim) - - for subcatch in pyswmm.Subcatchments(sim): - #print(subcatch.subcatchmentid) - # create a string that records the path we travel to get to the outfall - path_of_travel = list() - # can i grab the rain gage id? - path_of_travel.append((subcatch.subcatchmentid,"Subcatchment")) - current_id = subcatch.connection # grab the id of the next object downstream - - - try: # if the downstream connection is a subcatchment - current = Subcatchments[current_id] - current_id = current.subcatchmentid - subcatch = Subcatchments[current_id] - current_id = subcatch.connection # grab the id of the next object downstream - path_of_travel.append((current_id,'Subcatchment')) - except Exception as e: - #print("downstream connection was not another subcatchment") - #print(e) - pass - - # other option is that downstream connection is a node - # in which case we'll start iterating down through nodes and links to the outfall - current = Nodes[current_id] - path_of_travel.append((current_id,'Node')) - while not current.is_outfall(): - #print(path_of_travel) - # if the current object is a node, iterate through the links to find the downstream object - if current_id in pyswmm.Nodes(sim): - for link in pyswmm.Links(sim): - #print(link.linkid) - if link.inlet_node == current_id: - path_of_travel.append((link.linkid,"Link")) - current_id = link.outlet_node - path_of_travel.append((current_id,"Node")) - break - else: - print("current element is a sink (no link draining). verify this is correct") - print(current_id) - break - # if the current object is a link, grab the downstream node - elif current_id in pyswmm.Links(sim): - path_of_travel.append((link.linkid,"Link")) - current_id = current.outlet_node - path_of_travel.append((current_id,"Node")) - - - current = Nodes[current_id] - - #print("path of travel") - #print(path_of_travel) - # cut all the entries in path_of_travel that are not observable states or actions - original_path_of_travel = path_of_travel.copy() - - for step in original_path_of_travel: - step_is_state = False - step_is_control_input = False - for state in pystorms_scenario.config['states']: - if step[0] == state[0]: # same id - if ((step[1] == "Node" and "N" in state[1]) - or (step[1] == "Node" and 'flooding' in state[1]) - or (step[1] == "Node" and 'inflow' in state[1]) - or (step[1] == "Link" and "L" in state[1]) - or (step[1] == "Link" and 'flow' in state[1])): # types match - step_is_state = True - for control_input in pystorms_scenario.config['action_space']: - if step[0] == control_input: - step_is_control_input = True - if not step_is_state and not step_is_control_input: - path_of_travel.remove(step) # this will change the index, hence the "while" - ''' - print("full path of travel") - print(original_path_of_travel) - print("observable path of travel") - print(path_of_travel) - ''' - # iterate through the path of travel and rename the steps to align with the columns and indices of A and B - for step in path_of_travel: - for state in pystorms_scenario.config['states']: - if step[0] == state[0]: # same id - if ((step[1] == "Node" and "N" in state[1]) - or (step[1] == "Node" and 'flooding' in state[1]) - or (step[1] == "Node" and 'inflow' in state[1]) - or (step[1] == "Link" and "L" in state[1]) - or (step[1] == "Link" and 'flow' in state[1])): # types match - path_of_travel[path_of_travel.index(step)] = state - - for control_input in pystorms_scenario.config['action_space']: - if step[0] == control_input: - path_of_travel[path_of_travel.index(step)] = control_input - - #print("observable path of travel") - #print(path_of_travel) - - # now, use this path of travel to update the A and B matrices - #print("updating A and B matrices") - - # only use "i" if the entries have the same id. otherwise characterize everything as delayed, "d" - # because our path of travel only includes the observable states and the action space, we just need to look immediately up and downstream - # only looking upstream would simplify things and be sufficient for many scenarios, but it would miss backwater effects - for step in path_of_travel: # all of these are either observable states or actions - if path_of_travel.index(step) == 0: # first entry, previous step not meaningful - prev_step = False - else: - prev_step = path_of_travel[path_of_travel.index(step)-1] - if path_of_travel.index(step) == len(path_of_travel)-1: # last entry, next step not meaningful) - next_step = False - else: - next_step = path_of_travel[path_of_travel.index(step)+1] - - if step in pystorms_scenario.config['action_space']: - continue # we're not learning models for the control inputs, so skip them - - if prev_step and prev_step in pystorms_scenario.config['states']: - - if re.search(r'\d+', ''.join(prev_step)).group() == re.search(r'\d+', ''.join(step)).group(): # same integer id - A.loc[[step],[prev_step]] = 'i' - else: - A.loc[[step],[prev_step]] = 'd' - elif prev_step and prev_step in pystorms_scenario.config['action_space']: - - if re.search(r'\d+', ''.join(prev_step)).group() == re.search(r'\d+', ''.join(step)).group(): # same integer id - B.loc[[step],[prev_step]] = 'i' - else: - B.loc[[step],[prev_step]] = 'd' - if next_step and next_step[0] in pystorms_scenario.config['states'] or next_step in pystorms_scenario.config['states']: - # this only handles integer ids, but some models have letter ids or alphanumeric ids (pystorms scenario delta) - if re.search(r'\d+', ''.join(next_step)).group() == re.search(r'\d+', ''.join(step)).group(): - A.loc[[step],[next_step]] = 'i' - else: - A.loc[[step],[next_step]] = 'd' - elif next_step and next_step[0] in pystorms_scenario.config['action_space'] or next_step in pystorms_scenario.config['action_space']: - - if re.search(r'\d+', ''.join(next_step)).group() == re.search(r'\d+', ''.join(step)).group(): - B.loc[[step],[next_step]] = 'i' - else: - B.loc[[step],[next_step]] = 'd' - - - - - - ''' - for step in path_of_travel: - for state in pystorms_scenario.config['states']: - last_step = False - if step[0] == state[0]: # same id - if ((step[1] == "Node" and "N" in state[1]) - or (step[1] == "Node" and 'flooding' in state[1]) - or (step[1] == "Node" and 'inflow' in state[1])): # node type - # we've found a step in the path of travel which is an observable state - # are there any other observable states or controllabe assets in the path of travel? - for other_step in path_of_travel: - if path_of_travel.index(step) - path_of_travel.index(other_step) > 1: # other step is not immediately upstream - continue - if other_step == step: - last_step = True # we only want to look one object downstream - continue # this is the same step, so skip it - # if you want only objects that are upstream, substitude that continue with a "break" - - # we'll include states that come after the examined state in case of feedback such as backwater effects - for other_state in pystorms_scenario.config['states']: - if other_step[0] == other_state[0]: # same id - if ((other_step[1] == "Node" and "N" in other_state[1]) - or (other_step[1] == "Node" and 'flooding' in other_state[1]) - or (other_step[1] == "Node" and 'inflow' in other_state[1])): # node type - A.loc[[state],[other_state]] = 'd' - #print(A) - elif ((other_step[1] == "Link" and "L" in other_state[1]) - or (other_step[1] == "Link" and 'flow' in other_state[1])): - A.loc[[state],[other_state]] = 'd' - #print(A) - for control_asset in pystorms_scenario.config['action_space']: - if other_step[0] == control_asset[0]: - B.loc[[state],[control_asset]] = 'd' - #print(B) - if last_step: # just look at the next little bit downstream for backwater effects - break - - - elif ((step[1] == "Link" and "L" in state[1]) - or (step[1] == "Link" and 'flow' in state[1])): - for other_step in path_of_travel: - if path_of_travel.index(step) - path_of_travel.index(other_step) > 1: # other step is not immediately upstream - continue - if other_step == step: - last_step = True # we only want to look a limited distance downstream - continue # this is the same step, so skip it - for other_state in pystorms_scenario.config['states']: - if other_step[0] == other_state[0]: # same id - if ((other_step[1] == "Node" and "N" in other_state[1]) - or (other_step[1] == "Node" and 'flooding' in other_state[1]) - or (other_step[1] == "Node" and 'inflow' in other_state[1])): # node type - A.loc[[state],[other_state]] = 'd' - #print(A) - elif ((other_step[1] == "Link" and "L" in other_state[1]) - or (other_step[1] == "Link" and 'flow' in other_state[1])): - A.loc[[state],[other_state]] = 'd' - #print(A) - for control_asset in pystorms_scenario.config['action_space']: - if other_step[0] == control_asset[0]: - B.loc[[state],[control_asset]] = 'd' - if last_step: # just look at the next little bit downstream for backwater effects - break - for action in pystorms_scenario.config['action_space']: - if step[0] == action[0] or step[0] == action: - print(step) - print(action) - ''' - - #print(A) - #print(B) - - # add "i's" on the diagonal of A (instantaneous autocorrelatoin) - for idx in A.index: - A.loc[[idx],[idx]] = 'i' - # fill the na's in A and B with 'n' - A.fillna('n',inplace=True) - B.fillna('n',inplace=True) - - # concatenate the A and B matrices column-wise and return that result - causative_topology = pd.concat([A,B],axis=1) - - #print(causative_topology) - - return causative_topology - - -# this is for visuzliation, not building models. -# to build models, use the function above -def subway_map_from_pystorms(pystorms_scenario): - # remove any duplicates in the state or action space of the config - # this is an error within pystorms - pystorms_scenario.config['states'] = list(dict.fromkeys(pystorms_scenario.config['states'])) - pystorms_scenario.config['action_space'] = list(dict.fromkeys(pystorms_scenario.config['action_space'])) - - # make the index the concatentation of the states and action space - index = list(list(pystorms_scenario.config['states']) + list(pystorms_scenario.config['action_space'])) - - - - adjacency = pd.DataFrame(index = index , columns = index ).fillna(0) - - - # use pyswmm to iterate through the network - with pyswmm.Simulation(pystorms_scenario.config['swmm_input']) as sim: - # start at each subcatchment and iterate down to the outfall - # this should work even in the case of multiple outfalls - # this should capture all the causation, because ultimately everything is precip driven - - # so i can view these while debugging - Subcatchments = pyswmm.Subcatchments(sim) - Nodes = pyswmm.Nodes(sim) - Links = pyswmm.Links(sim) - - for subcatch in pyswmm.Subcatchments(sim): - #print(adjacency) - #print(subcatch.subcatchmentid) - # create a string that records the path we travel to get to the outfall - path_of_travel = list() - # can i grab the rain gage id? - path_of_travel.append((subcatch.subcatchmentid,"Subcatchment")) - current_id = subcatch.connection # grab the id of the next object downstream - - - try: # if the downstream connection is a subcatchment - current = Subcatchments[current_id] - current_id = current.subcatchmentid - subcatch = Subcatchments[current_id] - current_id = subcatch.connection # grab the id of the next object downstream - path_of_travel.append((current_id,'Subcatchment')) - except Exception as e: - #print("downstream connection was not another subcatchment") - #print(e) - pass - - # other option is that downstream connection is a node - # in which case we'll start iterating down through nodes and links to the outfall - current = Nodes[current_id] - path_of_travel.append((current_id,'Node')) - while not current.is_outfall(): - #print(path_of_travel) - # if the current object is a node, iterate through the links to find the downstream object - if current_id in pyswmm.Nodes(sim): - for link in pyswmm.Links(sim): - #print(link.linkid) - if link.inlet_node == current_id: - path_of_travel.append((link.linkid,"Link")) - current_id = link.outlet_node - path_of_travel.append((current_id,"Node")) - break - else: - print("current element is a sink (no link draining). verify this is correct") - print(current_id) - break - # if the current object is a link, grab the downstream node - elif current_id in pyswmm.Links(sim): - path_of_travel.append((link.linkid,"Link")) - current_id = current.outlet_node - path_of_travel.append((current_id,"Node")) - - - current = Nodes[current_id] - - #print("path of travel") - #print(path_of_travel) - # cut all the entries in path_of_travel that are not observable states or actions - original_path_of_travel = path_of_travel.copy() - - for step in original_path_of_travel: - step_is_state = False - step_is_control_input = False - for state in pystorms_scenario.config['states']: - if step[0] == state[0]: # same id - if ((step[1] == "Node" and "N" in state[1]) - or (step[1] == "Node" and 'flooding' in state[1]) - or (step[1] == "Node" and 'inflow' in state[1]) - or (step[1] == "Link" and "L" in state[1]) - or (step[1] == "Link" and 'flow' in state[1])): # types match - step_is_state = True - for control_input in pystorms_scenario.config['action_space']: - if step[0] == control_input: - step_is_control_input = True - if not step_is_state and not step_is_control_input: - path_of_travel.remove(step) # this will change the index, hence the "while" - - #print("full path of travel") - #print(original_path_of_travel) - #print("observable path of travel") - #print(path_of_travel) - - - # iterate through the path of travel and rename the steps to align with the columns of the adjacency - for step in path_of_travel: - for state in pystorms_scenario.config['states']: - if step[0] == state[0]: # same id - if ((step[1] == "Node" and "N" in state[1]) - or (step[1] == "Node" and 'flooding' in state[1]) - or (step[1] == "Node" and 'inflow' in state[1]) - or (step[1] == "Link" and "L" in state[1]) - or (step[1] == "Link" and 'flow' in state[1])): # types match - path_of_travel[path_of_travel.index(step)] = state - - for control_input in pystorms_scenario.config['action_space']: - if step[0] == control_input: - path_of_travel[path_of_travel.index(step)] = control_input - - - #print("observable path of travel") - #print(path_of_travel) - - # now, use this path of travel to update the adjacency - - # only use "i" if the entries have the same id. otherwise characterize everything as delayed, "d" - # because our path of travel only includes the observable states and the action space, we just need to look immediately up and downstream - # only looking upstream would simplify things and be sufficient for many scenarios, but it would miss backwater effects - for step in path_of_travel: # all of these are either observable states or actions - if path_of_travel.index(step) == 0: # first entry, previous step not meaningful - prev_step = False - else: - prev_step = path_of_travel[path_of_travel.index(step)-1] - if path_of_travel.index(step) == len(path_of_travel)-1: # last entry, next step not meaningful - next_step = False - else: - next_step = path_of_travel[path_of_travel.index(step)+1] - - # formatted as from row to column - if prev_step: - adjacency.loc[[prev_step],[step]] = 1 - if next_step: - adjacency.loc[[step],[next_step]] = 1 - - graph = nx.from_pandas_adjacency(adjacency,create_using=nx.DiGraph) - if not nx.is_directed_acyclic_graph(graph): - print("graph is not a DAG") - plt.figure(figsize=(20,10)) - pos = nx.planar_layout(graph) - nx.draw_networkx_nodes(graph, pos, node_size=500) - nx.draw_networkx_labels(graph, pos, font_size=12) - nx.draw_networkx_edges(graph, pos, arrows=True,arrowsize=30,style='solid',alpha=1.0) - plt.show() - - # we're now gauranteed to have a directed acycilce graph, so get the topological generations and use that as the subset key - gens = nx.topological_generations(graph) - gen_idx = 1 - for generation in gens: - #print(generation) - for node in graph.nodes: - if node in generation: - graph.nodes[node]['generation'] = gen_idx - gen_idx += 1 - - # but to draw without overlaps, we need to partition by the root node, not the generation - # give each node a key corresponding to its most distant ancestor - # then, we can use that key to partition the nodes and draw them in separate columns - for node in graph.nodes: - #print(node) - #print(nx.ancestors(graph,node)) - ancestors = nx.ancestors(graph,node) - most_distant_ancestor = node - for ancestor in ancestors: - distance = nx.shortest_path_length(graph,ancestor,node) - if distance > nx.shortest_path_length(graph,most_distant_ancestor,node): - most_distant_ancestor = ancestor - graph.nodes[node]['root'] = most_distant_ancestor - #print(most_distant_ancestor) - - - return {'adjacency':adjacency,'index':index,'graph':graph} - diff --git a/pyproject.toml b/pyproject.toml index 972eeef..2314cb4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,12 +15,7 @@ dependencies = [ "matplotlib>=3.7", "control>=0.9", "pysindy>=2.0", - "cvxpy>=1.3", "networkx>=3.0", - "pyswmm>=2.0", - "pystorms>=1.0", - "statsmodels>=0.14", - "dill>=0.3", ] [tool.setuptools.packages.find] diff --git a/requirements.txt b/requirements.txt index 383a7c9..75bc631 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,12 +5,7 @@ scipy>=1.10 matplotlib>=3.7 control>=0.9 pysindy>=2.0 -cvxpy>=1.3 networkx>=3.0 -pyswmm>=2.0 -pystorms>=1.0 -statsmodels>=0.14 -dill>=0.3 # Testing pytest>=7.0 diff --git a/test_lti_control_of_swmm_plant_approx.png b/test_lti_control_of_swmm_plant_approx.png deleted file mode 100644 index 39ccc6266c308796b3f1279d6a895e663c538f4f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 82597 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 diff --git a/test_lti_control_of_swmm_plant_approx.svg b/test_lti_control_of_swmm_plant_approx.svg deleted file mode 100644 index 6998825..0000000 --- a/test_lti_control_of_swmm_plant_approx.svg +++ /dev/null @@ -1,2187 +0,0 @@ - - - - - - - - 2023-09-16T16:27:52.319358 - image/svg+xml - - - Matplotlib v3.7.1, https://matplotlib.org/ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/test_lti_control_of_swmm_plant_approx_first10.png b/test_lti_control_of_swmm_plant_approx_first10.png deleted file mode 100644 index 29e9d8ba456ad3a299c7c9f4b999511df973e0c7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 44136 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ⅇ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 diff --git a/test_lti_control_of_swmm_plant_approx_first10.svg b/test_lti_control_of_swmm_plant_approx_first10.svg deleted file mode 100644 index 07cba91..0000000 --- a/test_lti_control_of_swmm_plant_approx_first10.svg +++ /dev/null @@ -1,1578 +0,0 @@ - - - - - - - - 2023-09-16T16:27:53.303486 - image/svg+xml - - - Matplotlib v3.7.1, https://matplotlib.org/ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/test_lti_system_gen.png b/test_lti_system_gen.png deleted file mode 100644 index 5d9f99e19acaa7a612a941654781f939e944ac1d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 89180 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

u<2Zf zziu4L@>I!l(8_ZpxeuTeA3KAGdxIOhFRlza0l%O>kaC)so6GQ2VTTG{v@hS#Z2QZHNjs zX^e?XeGz0s*F~VmhML&>D4!my=P;4ixdea)0`}rd9^0BIgC^L&$N6zehkwfXK`REfW%%=QRk6V>1`M$*H zWxNe0Lw5LNsM(Ig%N1V(LooXOy%Q+uOw`@rhHd%5F$>m!v5L|jKbic zkWiPk8S}J)F=B+GhUT?ELD##HwQA1eCr)Va(6yDA6)Cp7{Lr&?)c~Yee%~s%y~)h- z`2;z_LEpf7=W6T$?6&+aO_pacc*>x6KD5$EyW~XG4;+&bz#^=oHN5>AHf=}5m#d#6 z6Tm$c*5r9l?tY+85fI|R#}*fTysBhsZG9wBE8_L**M=9a!U+?EBBwA5=A#=D^kVkUk zfnq`XGidDtYh^pi8V>OddY)zS`6V>&jvE~{f6^Jxe)h5|!lUA^`j2r z|L)Q4U_L#0xW~^ZAm>vgF%9j3&_`F*qP6_d@4D>>!`j~xJbb37BiciOVUQ9ycz|KO zdtnWtf)ireYwmZ&8)i4_9jox3WIblwM2KHZ&upCI)!`;_tf1(a5RZ)f ziYOZrx_~>p>?Kvb9r0d3_^n(ve)JYB$PTbtcW9m4vfOB8IuVBEVkH~0fqRt04VA4- z#S_Gc2xVgD(bfLp2DnyrJ!_=ls2N%5bkP)t&+lum4ZMuqF~j%&T<65n5T!*bChn0Y zHGT25#l-1%<<73J$o8-G8ImY}*n}Z{f20kL< zY#tc9vP35$)0^~&hsFYqU$<|8od-WktdG+{?pYar|RTc7_Oa0K}_PB%C!h43Eu0jo=x{n~oe+G{UNy^sE&YFE%Nn8y-IX3aki zk!*W=iUK|Ic_R-~P~}lhhs&8J@-n?R|HJmyVTCR}f(ZR?BH0`EfNigeqGU>)GZ2(w zkB16qiplU5_+Ng*MuX^J4RGFehY}O!RQ?|+)HR~|y5y+pDR{Bl)bQ;b<{TR2v-j`s z#ML$5n+t34(F7OCK0TvuHeO-MgZs|=tqwN7OQrLv{Zk_ySH2uvNGFhy3h_Kvv~S4{ zC3)~|*DvHzkbP;=#|nbWMy2pKPiuJ$VlZZ+gZn`CFq2Z4gPAxjy?Vb@2upJWX2N$* z*YA%eH$#fUd;o%r&nmsY+n)sGpl1Ovy2Yhw5ti&|IiZjdUy-5OAFAA6$u&L}6w^TJW zG)yaXbG7O!l&^&0sECFNOyyzHO)AjGlCoSF8!%A6nfF$Xe4&b8ONoIF-ofKp(s%+{=CnD(0q;VI|Cyzi%e!?cv!`*-C->tBfr*ql+@wSpl*=8J zI$W!tPue&D4(8*mV?4vMG!^Pc+P=FZr%VHbo)&#!L>|)mJ^nUvDU?9Q64M=;o~F!n zqxi4G3`0s_0Q-y?AJrptz$L1v&U1gCY2^kV76x7o67$rYoJ8PU_n9J^+YX%dYvPyv z`b0oF=z4><_nGy}o>vtw&?3d-o=5EY*n(BKxx2E zg*@yIerZt@_-%pYaep6X880LMr0~jIbLFcW;@MyKNrMbREevhn#+v)J^A*@T6a6?1 znov+|Lkj(E7`6BPi5+TFyY3mZkZJG7

iCvU>qp7Y&Ywgh3*dF!5vjg!bZtGGYc+ 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�AfjY)LM2JQ08El+Hup!z(Ht$ox&yf&evLW%6 z#r)EM#eM43sg70&SO0^}NZ*F%mU$;*w*Qn&kW5hNjV^(s5CUmT=+5-0fwu9+OJk8p z6uk?@%BSI}HNuf?@1C691Q0R^bFQ{QCALYWsJfaF0qtUyw&87ii0KTo<-=B2-Sl&n z*#cWs=axJc`mnHgo3Bsy3Zy7?oB*SjdRJh>}fmbZLMb^(Ys~!4x zHriT&Dd_y0y{&+}4S%8~(eg7*5^D7jfB>-kB^?~+&8(dIUeMTRy)wiHRY=PTH`z^? zy*R)@ZM>hJhT`5FEEXp)sdE4{Vg_y0Ie=j@kdl(lDb4&z>PQ`cDhThV;NNy8=g$kF zQtq*^zX@PXApGHvq;mr8TRsU1Bj^^L3EJE2b%J0S{qKl2=W#H$&CSYHUW=~?`kQtf@jsQ36)#!g8g~|E!0T`?RY>VivDE}fN;8@ zgV_!87+p|a5P^c2p!>@|m%(HH337rn+D;(mTu@ytGj~W{F@6#rX}sKS4y38eAb@0o ztwkr%yb^umc&cS>zL#M6d;;C3vrKsTP zQ@wV=)Gff>lOUdOp?{-yb8UKZc6|WK$B}eDMiY;!{7eBuNKY1_4#Kf5A6vm38BPan zz2OrS)U+%+zqt;DzK;1#5w6>S2rW8PTLs-u3f``l!Mk`9lhn#dw+e zpCeW;L49ub>n!Q0&RM#^7JDcr$AjSYi;vtOC49Ta$&TyTvq7*&3g5QYbTlZiiFXkRvE2HBBI@4jh8dha3Ko0>HgPTDd_?x*H6g zgABXW<=3oxZege9`ud`goT}9$w8QUS|i^$rV17)x!T|1i{*&S!ZCVRREKhxikZi%ZV3P zj=qFQ;!MT(JgOH446ou4fdu_sDVk{XJ&7K_fa&Hu+)_J?sJS){OU4mA4t6}?;LnmL z#0l*Vk`AyX`rf?hhS%O$eK9z0GDOgY3Tl%TVZK28)*LA?d6euO|u#ayja z3o6VQ9K=0}PKP&JckM7ZfK_VRf*^47A?!U3Xl6Bi6bJt;1Y`@Hz2dg@Jnu$EG_|y% zH+J`D_aTVJ!jeHD9~ac7T9|U6;%s6 zUncOZz`)K6dqN=SwR_z>9Es7qeq9WOT7Ei&)UI>=_dNPQ_yFXdYOo^I5@OIulq>m4 z+o1{`AA$Ymr6&O3l#I_%D}3jE0Y&0F07&3_lHHy$gemxt5LM(Mz{-#fi1TKFIM?;2 zp@9JnB$Y{ADj#M~#}bNfTV|T^!D`I-3^nSeV8xvBDd&(@wPxt!(IS-)%89R!O-xKI z$;0n@KN~on3ahce-EPkVA}mOf&dIaGGKI*DQ9_<9Qil{;J_rvhsGJ_&2-$joghHkW^Dsa|a~lJqE<;9Kcl$xz^m1y8$ON)cjU0l@Ir}>i2(5d7?EM2!vTx z0%ZVQ0JJ!p?d050RQ?QZODZ-78an7Kz& - - - - - - - 2023-12-08T15:28:46.809798 - image/svg+xml - - - Matplotlib v3.7.1, https://matplotlib.org/ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/test_lti_system_gen_cartoon.svg b/test_lti_system_gen_cartoon.svg deleted file mode 100644 index e72332f..0000000 --- a/test_lti_system_gen_cartoon.svg +++ /dev/null @@ -1,438 +0,0 @@ - - - - - - - - 2023-12-04T19:10:24.908645 - image/svg+xml - - - Matplotlib v3.7.1, https://matplotlib.org/ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/test_lti_system_gen_obc.png b/test_lti_system_gen_obc.png deleted file mode 100644 index 9ed8cd90bb4d845e68a4cfd439401cd3b60168ab..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 81238 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 diff --git a/test_lti_system_gen_obc.svg b/test_lti_system_gen_obc.svg deleted file mode 100644 index db73d6f..0000000 --- a/test_lti_system_gen_obc.svg +++ /dev/null @@ -1,3205 +0,0 @@ - - - - - - - - 2023-12-08T15:28:53.735630 - image/svg+xml - - - Matplotlib v3.7.1, https://matplotlib.org/ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/test_topo_inference.png b/test_topo_inference.png deleted file mode 100644 index f0e694b445755f82140d0b9d927e8a912c8b105f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 45216 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 None: def test_transform_inputs_correctness() -> None: """transform_inputs must produce correct gamma-transformed outputs.""" np.random.seed(42) - n = 100 + n = 20 index = pd.date_range("2000-01-01", periods=n, freq="1h") - # Create forcing data forcing = pd.DataFrame({"u": np.cumsum(np.random.randn(n) * 0.1)}, index=index) - # Create parameter dataframes shape_factors = pd.DataFrame({"u": [2.0]}, index=[1]) scale_factors = pd.DataFrame({"u": [1.0]}, index=[1]) loc_factors = pd.DataFrame({"u": [0.0]}, index=[1]) - # Transform result = modpods.transform_inputs( shape_factors, scale_factors, loc_factors, index, forcing ) - # Check output assert "u_tr_1" in result.columns assert len(result) == n assert not result.isnull().values.any() - # Verify against direct convolution - from scipy import signal, stats - - forcing_values = forcing["u"].to_numpy() - shape_time = np.arange(0, n, 1) - gamma_kernel = stats.gamma.pdf(shape_time, 2.0, scale=1.0, loc=0.0) - expected = signal.fftconvolve(forcing_values, gamma_kernel, mode="full")[:n] + known_expected = np.array([ + 4.44089210e-17, 1.82730925e-02, 2.66312232e-02, 5.41349278e-02, + 1.29269010e-01, 1.72213335e-01, 1.85028295e-01, 2.46741125e-01, + 3.18644918e-01, 3.45925852e-01, 3.76226589e-01, 3.77780369e-01, + 3.57689577e-01, 3.51598612e-01, 2.79450476e-01, 1.63734986e-01, + 6.76750731e-02, -2.46014475e-02, -6.79339082e-02, -1.20732221e-01, + ]) - np.testing.assert_allclose(result["u_tr_1"].values, expected, rtol=1e-10) + np.testing.assert_allclose(result["u_tr_1"].values, known_expected, rtol=1e-5) def test_transform_inputs_with_cache() -> None: @@ -457,7 +453,7 @@ def bayesian_model(simple_lti_data: pd.DataFrame) -> dict[Any, Any]: windup_timesteps=0, init_transforms=1, max_transforms=1, - max_iter=20, + max_iter=10, poly_order=1, verbose=False, optimization_method="bayesian", @@ -477,7 +473,7 @@ def de_model(simple_lti_data: pd.DataFrame) -> dict[Any, Any]: windup_timesteps=0, init_transforms=1, max_transforms=1, - max_iter=20, + max_iter=10, poly_order=1, verbose=False, optimization_method="differential_evolution", @@ -497,7 +493,7 @@ def da_model(simple_lti_data: pd.DataFrame) -> dict[Any, Any]: windup_timesteps=0, init_transforms=1, max_transforms=1, - max_iter=20, + max_iter=10, poly_order=1, verbose=False, optimization_method="dual_annealing", @@ -721,8 +717,9 @@ def test_lti_system_gen_returns_state_space( cascade_lti_system_data, independent_columns=["u1", "u2"], dependent_columns=["x2", "x8", "x9"], - max_iter=10, + max_iter=5, bibo_stable=True, + max_transforms=1, ) assert isinstance(result, dict) @@ -779,7 +776,7 @@ def trained_camels_model( windup_timesteps=windup_timesteps, init_transforms=1, max_transforms=1, - max_iter=10, + max_iter=5, poly_order=1, verbose=False, bibo_stable=False, From 1a973a1f93159c647232d169f4ae5109d22a1fc1 Mon Sep 17 00:00:00 2001 From: Travis Adrian Dantzer <47285626+dantzert@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:38:22 -0400 Subject: [PATCH 2/3] fix: align CI with py312, resolve black AST mismatch --- .github/workflows/ci.yml | 2 +- modpods/metrics.py | 6 +----- modpods/model.py | 1 - tests/test_modpods.py | 31 ++++++++++++++++++++++++------- 4 files changed, 26 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6f670d..93f5872 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.11" + python-version: "3.12" - name: Install dependencies run: pip install -r requirements.txt diff --git a/modpods/metrics.py b/modpods/metrics.py index 62749a5..de5325a 100644 --- a/modpods/metrics.py +++ b/modpods/metrics.py @@ -14,11 +14,7 @@ def compute_basic_metrics(y_true, y_pred): error = y_true - y_pred mae = float(np.mean(np.abs(error))) rmse = float(np.sqrt(np.mean(error**2))) - nse = float( - 1 - - np.sum(error**2) - / np.sum((y_true - np.mean(y_true)) ** 2) - ) + nse = float(1 - np.sum(error**2) / np.sum((y_true - np.mean(y_true)) ** 2)) alpha = float(np.std(y_pred) / np.std(y_true)) beta = float(np.mean(y_pred) / np.mean(y_true)) return { diff --git a/modpods/model.py b/modpods/model.py index 6d380ec..72a4bc1 100644 --- a/modpods/model.py +++ b/modpods/model.py @@ -1,4 +1,3 @@ - import numpy as np import pandas as pd import pysindy as ps diff --git a/tests/test_modpods.py b/tests/test_modpods.py index ab3edc7..5381667 100644 --- a/tests/test_modpods.py +++ b/tests/test_modpods.py @@ -169,13 +169,30 @@ def test_transform_inputs_correctness() -> None: assert len(result) == n assert not result.isnull().values.any() - known_expected = np.array([ - 4.44089210e-17, 1.82730925e-02, 2.66312232e-02, 5.41349278e-02, - 1.29269010e-01, 1.72213335e-01, 1.85028295e-01, 2.46741125e-01, - 3.18644918e-01, 3.45925852e-01, 3.76226589e-01, 3.77780369e-01, - 3.57689577e-01, 3.51598612e-01, 2.79450476e-01, 1.63734986e-01, - 6.76750731e-02, -2.46014475e-02, -6.79339082e-02, -1.20732221e-01, - ]) + known_expected = np.array( + [ + 4.44089210e-17, + 1.82730925e-02, + 2.66312232e-02, + 5.41349278e-02, + 1.29269010e-01, + 1.72213335e-01, + 1.85028295e-01, + 2.46741125e-01, + 3.18644918e-01, + 3.45925852e-01, + 3.76226589e-01, + 3.77780369e-01, + 3.57689577e-01, + 3.51598612e-01, + 2.79450476e-01, + 1.63734986e-01, + 6.76750731e-02, + -2.46014475e-02, + -6.79339082e-02, + -1.20732221e-01, + ] + ) np.testing.assert_allclose(result["u_tr_1"].values, known_expected, rtol=1e-5) From c532713e67da2ebe2bf905fd8e9b4f4e22cf1220 Mon Sep 17 00:00:00 2001 From: Travis Adrian Dantzer <47285626+dantzert@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:43:51 -0400 Subject: [PATCH 3/3] fix: restore cvxpy dependency for pysindy ConstrainedSR3 pysindy.optimizers._constrained_sr3 imports cvxpy as cp at module load time, so removing cvxpy from pyproject.toml/requirements.txt causes import failures in CI even though modpods.py never directly imports cvxpy. Add it back. --- pyproject.toml | 1 + requirements.txt | 1 + 2 files changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 2314cb4..8583af2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "matplotlib>=3.7", "control>=0.9", "pysindy>=2.0", + "cvxpy>=1.3", "networkx>=3.0", ] diff --git a/requirements.txt b/requirements.txt index 75bc631..5e2d8e5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,7 @@ scipy>=1.10 matplotlib>=3.7 control>=0.9 pysindy>=2.0 +cvxpy>=1.3 networkx>=3.0 # Testing