mesas 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mesas/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """MESAS - Multiscale Estimation of StorAge Selection."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from mesas.sas.model import Model, ModelOptions, ModelResult, SoluteSpec
6
+
7
+ try:
8
+ __version__ = version("mesas")
9
+ except PackageNotFoundError:
10
+ # Running from an uninstalled source tree
11
+ __version__ = "2.0.0+dev"
12
+
13
+ __all__ = ["Model", "ModelOptions", "ModelResult", "SoluteSpec", "__version__"]
mesas/me/__init__.py ADDED
File without changes
@@ -0,0 +1,514 @@
1
+ """Recursive splitting algorithm for SAS function estimation.
2
+
3
+ Estimates piecewise-constant SAS functions by iteratively splitting
4
+ segments and optimising against observed solute concentrations using
5
+ cross-validated least-squares.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import inspect
11
+
12
+ import numpy as np
13
+ from scipy.optimize import least_squares
14
+ from scipy.stats import ttest_rel
15
+
16
+ VERBOSE = True
17
+
18
+ ITERATION = None
19
+
20
+
21
+ def _verbose(string, **kwargs):
22
+ """prints progress updates"""
23
+ if VERBOSE:
24
+ print(string, **kwargs)
25
+
26
+
27
+ def run(model, verbose=True, search_mode="leftfirst", **kwargs):
28
+ """
29
+ Estimates a SAS function that reproduces observations using a piecewise constant pdf
30
+
31
+ :param model: a sas model instance
32
+ :param components_to_learn: dict of list of str
33
+ :param verbose: If True, prints progress updates
34
+ :param kwargs:
35
+ :return:
36
+ """
37
+
38
+ # handle verbosity
39
+ global VERBOSE
40
+ VERBOSE = verbose
41
+ _verbose(f"Starting {inspect.stack()[0][3]}")
42
+
43
+ # make a copy of the input model
44
+ initial_model = model.copy_without_results()
45
+
46
+ _verbose("Initial model is:")
47
+ _verbose(model)
48
+
49
+ # Create a dictionary that will hold new components (sas functions) as we find them.
50
+ # It will also serve to keep track of which components to keep subdividing (if there is more than 1
51
+ # NOTE: a component is an object that packages together a sas function and a timeseries of its weight
52
+ # along with a label identifying it. It allows us to specify a time-varying SAS function as a
53
+ # weighted sum of fixed SAS functions
54
+ new_components = {}
55
+ for flux in initial_model.components_to_learn.keys():
56
+ new_components[flux] = dict((label, None) for label in initial_model.components_to_learn[flux])
57
+
58
+ index = initial_model.get_obs_index()
59
+
60
+ # Fit the initial model to the observed data
61
+ _verbose("-- Refining the initial model. ", end="")
62
+ better_initial_model, _ = fit_model(initial_model, index=index, **kwargs)
63
+
64
+ better_initial_rmse = cross_validation_rmse(better_initial_model, index=index, **kwargs)
65
+ _verbose(f"Initial rmse = {better_initial_rmse}")
66
+
67
+ if "incres_fun" in kwargs.keys():
68
+ kwargs["incres_fun"](better_initial_model, "initial")
69
+
70
+ # run the algorithm
71
+ global ITERATION
72
+ ITERATION = 0
73
+ if search_mode == "scanning":
74
+ return increase_resolution_scanning(
75
+ better_initial_model, better_initial_rmse, new_components, segment=0, index=index, **kwargs
76
+ )
77
+ elif search_mode == "leftfirst":
78
+ return increase_resolution_leftfirst(
79
+ better_initial_model, better_initial_rmse, new_components, segment=0, index=index, **kwargs
80
+ )
81
+ else:
82
+ print("Never be here")
83
+
84
+
85
+ def lookfor_new_components(
86
+ initial_model, initial_rmse, new_components, segment_dict, id_str, incres_fun=None, index=None, **kwargs
87
+ ):
88
+ # This keeps track of how many new components we found
89
+ new_components_count = 0
90
+ # This will keep track of convergence information provided by fit_model in case we want to use it for plotting
91
+ rmse_dict = {}
92
+
93
+ # These are retained in case they save us having to run the model again
94
+ last_accepted_model, last_accepted_rmse = None, None
95
+
96
+ # loop over the components we want to improve, testing whether subdividing each individually leads to
97
+ # substantial change in the fit
98
+ for flux in new_components.keys():
99
+ for label in new_components[flux].keys():
100
+ if isinstance(segment_dict, dict):
101
+ segment = segment_dict[flux][label]
102
+ else:
103
+ segment = segment_dict
104
+
105
+ nsegment = initial_model.sas_specs[flux].components[label].sas_fun[0].nsegment
106
+ if segment < nsegment:
107
+ _verbose(f"Subdividing {flux} {label} segment {segment}")
108
+
109
+ largest_observed_ST = (initial_model.result["sT"].sum(axis=0) * initial_model.options["dt"]).max()
110
+ smallest_ST_in_subdivided_segment = (
111
+ initial_model.sas_specs[flux].components[label].sas_fun[0].ST[segment]
112
+ )
113
+ if smallest_ST_in_subdivided_segment > largest_observed_ST:
114
+ _verbose("Subdivision is beyond observed ST. Shouldn't make a difference.")
115
+ subdivision_accepted = False
116
+ new_rmse = None
117
+
118
+ else:
119
+ new_model = initial_model.subdivided_copy(flux, label, segment)
120
+ segment_list = new_model.get_parameter_list()
121
+ if np.any(segment_list[1:] < new_model.options["ST_smallest_segment"]):
122
+ _verbose("New segment too small! Try reducing model.options['ST_smallest_segment']")
123
+ subdivision_accepted = False
124
+ new_rmse = None
125
+ else:
126
+ _verbose("-- Initial fit of the candidate model ", end="")
127
+ new_model, new_rmse = fit_model(new_model, index=index, **kwargs)
128
+
129
+ # The current criteria used for deciding whether to accept the subdivision is simple:
130
+ # Just check whether the model improvement is greater than some threshold
131
+ # This can definitely be improved
132
+ # subdivision_accepted = (1 - new_rmse / initial_rmse) > split_tolerance
133
+
134
+ # Use k-fold cross validation to see if the split leads to an improvement in the model
135
+ new_rmse = cross_validation_rmse(new_model, index=index, **kwargs)
136
+ _verbose("Candidate model = ")
137
+ _verbose(new_model)
138
+ rmse_change = np.mean(new_rmse - initial_rmse)
139
+ rmse_pvalue = ttest_rel(new_rmse, initial_rmse).pvalue
140
+ subdivision_accepted = rmse_pvalue < 0.95 and rmse_change < 0
141
+
142
+ _verbose(f"Initial rmse = {initial_rmse}")
143
+ _verbose(f"Candidate rmse = {new_rmse}")
144
+ _verbose(f"Mean change in rmse = {rmse_change}")
145
+ _verbose(f"t-test p value = {rmse_pvalue}")
146
+ # Optionally, make some plots
147
+ if incres_fun is not None:
148
+ incres_fun(
149
+ new_model, f"{id_str}_{flux}_{label}_{nsegment}_{segment}_{subdivision_accepted}"
150
+ )
151
+
152
+ # accept or reject the subdivision
153
+ if subdivision_accepted:
154
+ _verbose(f"Subdivision accepted for {flux}, {label} segment {segment}")
155
+
156
+ # Add to the record of new components, and increment the counter
157
+ new_components[flux][label] = new_model.sas_specs[flux].components[label]
158
+ new_components_count += 1
159
+ last_accepted_model, last_accepted_rmse = new_model, new_rmse
160
+
161
+ else:
162
+ _verbose(f"Subdivision rejected for {flux}, {label} segment {segment}")
163
+
164
+ # Record the rejection by setting the dict entry to None
165
+ new_components[flux][label] = None
166
+
167
+ # Keep a record of the rmse for plotting
168
+ rmse_dict[f"{flux}, {label}"] = new_rmse
169
+
170
+ return last_accepted_model, last_accepted_rmse, new_components, new_components_count, rmse_dict
171
+
172
+
173
+ def incorporate_new_components(initial_model, new_components):
174
+ """
175
+ Adds components into a model from a dictionary
176
+
177
+ :param initial_model:
178
+ :param new_components:
179
+ :param index:
180
+ :param kwargs:
181
+ :return:
182
+ """
183
+ new_model = initial_model.copy_without_results()
184
+
185
+ for flux in new_components.keys():
186
+ for label in new_components[flux].keys():
187
+ if new_components[flux][label] is not None:
188
+ new_model.set_component(flux, new_components[flux][label])
189
+
190
+ return new_model
191
+
192
+
193
+ def increase_resolution_scanning(
194
+ initial_model, initial_rmse, new_components, maxscan=100, index=None, incres_fun=None, **kwargs
195
+ ):
196
+ """Increases sas function resolution by splitting a piecewise linear segment (scanning method)
197
+
198
+ :param initial_model: The model we want to try increasing the resolution of
199
+ :param initial_rmse: How well it currently fits the data
200
+ :param new_components: A dict that keeps track of which components we are trying to refine
201
+ :param segment: The index of the segment we are currently refining
202
+ :param kwargs: Additional arguments to be passed to the fit_model function
203
+ :return:
204
+ """
205
+
206
+ _verbose(f"\n***********************\nStarting {inspect.stack()[0][3]}")
207
+
208
+ any_segments_subdivided = False
209
+ new_model = None
210
+
211
+ for scan in range(maxscan):
212
+ _verbose(f"\nStarting scan #{scan + 1}")
213
+
214
+ # How many segments do we have to scan?
215
+ max_segment = 0
216
+ check_segment_dict = {}
217
+ for flux in new_components.keys():
218
+ check_segment_dict[flux] = {}
219
+ for label in new_components[flux].keys():
220
+ if max_segment < initial_model.sas_specs[flux].components[label].sas_fun[0].nsegment:
221
+ max_segment = initial_model.sas_specs[flux].components[label].sas_fun[0].nsegment
222
+ # start with segment 0
223
+ check_segment_dict[flux][label] = 0
224
+
225
+ for segment in range(max_segment):
226
+ _verbose(f"Testing increased SAS resolution in segment {segment}")
227
+
228
+ last_accepted_model, last_accepted_rmse, new_components, new_components_count, rmse_dict = (
229
+ lookfor_new_components(
230
+ initial_model,
231
+ initial_rmse,
232
+ new_components,
233
+ check_segment_dict,
234
+ id_str=f"scan_{scan}",
235
+ incres_fun=incres_fun,
236
+ index=index,
237
+ **kwargs,
238
+ )
239
+ )
240
+
241
+ # if we accepted refined components we want to add them into the model
242
+ # before moving on to the next segment or scan
243
+ if new_components_count > 0:
244
+ any_segments_subdivided = True
245
+
246
+ # If we found more than one new component, we need to include all of them, then re-run
247
+ # fit_model so that their interactions can be accounted for
248
+ if new_components_count > 1:
249
+ _verbose("-- Combining and fitting ", end="")
250
+ new_model, _ = fit_model(
251
+ incorporate_new_components(initial_model, new_components), index=index, **kwargs
252
+ )
253
+ new_rmse = cross_validation_rmse(new_model, index=index, **kwargs)
254
+
255
+ else:
256
+ new_model, new_rmse = last_accepted_model, last_accepted_rmse
257
+
258
+ # Optionally, make some plots
259
+ if incres_fun is not None:
260
+ incres_fun(new_model, f"scan_{scan}_all_components_{max_segment}_{segment}")
261
+
262
+ _verbose("New model is:")
263
+ _verbose(new_model)
264
+
265
+ initial_model = new_model
266
+ initial_rmse = new_rmse
267
+
268
+ # Increment the segment counter
269
+ for flux in check_segment_dict.keys():
270
+ for label in check_segment_dict[flux].keys():
271
+ if new_components[flux][label] is not None:
272
+ check_segment_dict[flux][label] += 1
273
+ if (
274
+ check_segment_dict[flux][label]
275
+ < initial_model.sas_specs[flux].components[label].sas_fun[0].nsegment
276
+ ):
277
+ check_segment_dict[flux][label] += 1
278
+
279
+ if any_segments_subdivided:
280
+ any_segments_subdivided = False
281
+ _verbose(f"Scan {scan + 1} complete")
282
+ else:
283
+ # If not, we are done. Return the model we've found,
284
+ _verbose("Finished increasing model resolution")
285
+ return new_model
286
+
287
+ if new_model is not None:
288
+ _verbose(f"Maximum number of scans reached ({maxscan})")
289
+ return new_model
290
+ else:
291
+ return initial_model
292
+
293
+
294
+ def increase_resolution_leftfirst(
295
+ initial_model, initial_rmse, new_components, segment, incres_fun=None, index=None, **kwargs
296
+ ):
297
+ """Increases sas function resolution by splitting a piecewise linear segment (left-first method)
298
+
299
+ :param initial_model: The model we want to try increasing the resolution of
300
+ :param initial_rmse: How well it currently fits the data
301
+ :param new_components: A dict that keeps track of which components we are trying to refine
302
+ :param segment: The index of the segment we are currently refining
303
+ :param incres_fun: An optional function that can produce some plots along the way
304
+ :param kwargs: Additional arguments to be passed to the fit_model function
305
+ :return:
306
+ """
307
+
308
+ global ITERATION
309
+ ITERATION += 1
310
+
311
+ _verbose(f"\n***********************\nStarting {inspect.stack()[0][3]} iteration {ITERATION}")
312
+ _verbose(f"Testing increased SAS resolution in segment {segment}")
313
+
314
+ # total number of segments. Used for plotting (actually for naming the saved figure files)
315
+ _Ns = len(initial_model.get_parameter_list()) # noqa: F841
316
+
317
+ last_accepted_model, last_accepted_rmse, new_components, new_components_count, rmse_dict = lookfor_new_components(
318
+ initial_model,
319
+ initial_rmse,
320
+ new_components,
321
+ segment,
322
+ id_str=f"iteration_{ITERATION}",
323
+ incres_fun=incres_fun,
324
+ index=index,
325
+ **kwargs,
326
+ )
327
+
328
+ # if we accepted refined components we want to add them into the model
329
+ # and then recursively try subdividing them too
330
+ if new_components_count > 0:
331
+ # If we found more than one new component, we need to include all of them, then re-run
332
+ # fit_model so that their interactions can be accounted for
333
+ if new_components_count > 1:
334
+ _verbose("-- Combining and fitting ", end="")
335
+ new_model, _ = fit_model(incorporate_new_components(initial_model, new_components), index=index, **kwargs)
336
+ new_rmse = cross_validation_rmse(new_model, index=index, **kwargs)
337
+
338
+ else:
339
+ new_model, new_rmse = last_accepted_model, last_accepted_rmse
340
+
341
+ # Optionally, make some plots
342
+ if incres_fun is not None:
343
+ incres_fun(new_model, f"iteration_{ITERATION}_all_components")
344
+
345
+ _verbose("New model is:")
346
+ _verbose(new_model)
347
+
348
+ # Recursively try subdividing the first of the two new segments
349
+ return increase_resolution_leftfirst(
350
+ new_model, new_rmse, new_components, segment, incres_fun=incres_fun, index=index, **kwargs
351
+ )
352
+
353
+ # If we didn't accept any new subdivisions, we need to move on
354
+ else:
355
+ # First, we check whether each component even has more segments to subdivide
356
+ more_segments = False
357
+ for flux in list(new_components.keys()).copy():
358
+ for label in list(new_components[flux].keys()).copy():
359
+ if segment == initial_model.sas_specs[flux].components[label].sas_fun[0].nsegment - 1:
360
+ # no more segments in this component, so delete it from the dict
361
+ _verbose(f"No more refinement for {flux}, {label}")
362
+ del new_components[flux][label]
363
+
364
+ else:
365
+ more_segments = True
366
+
367
+ # did any?
368
+ if more_segments:
369
+ # If so, recursively call this function on the next segment
370
+ _verbose(f"Moving to segment {segment + 1}")
371
+ return increase_resolution_leftfirst(
372
+ initial_model, initial_rmse, new_components, segment + 1, incres_fun=incres_fun, index=index, **kwargs
373
+ )
374
+ else:
375
+ # If not, we are done. Return the model we've found,
376
+ _verbose("Finished increasing model resolution")
377
+ return initial_model
378
+
379
+
380
+ def fit_model(model, include_C_old=True, learn_fun=None, index=None, jacobian_mode="numerical", **kwargs):
381
+ """
382
+ Fit the sas function to the data using a least-squares regression optimization
383
+
384
+ :param model:
385
+ :param include_C_old:
386
+ :param learn_fun:
387
+ :param index:
388
+ :param jacobian_mode: 'numerical' (default). 'analytical' is not currently
389
+ supported: the Numba solver does not implement parameter sensitivities,
390
+ so analytical jacobians would be silently zero.
391
+ :param kwargs:
392
+ :return:
393
+ """
394
+
395
+ if jacobian_mode == "analytical":
396
+ raise NotImplementedError(
397
+ "jacobian_mode='analytical' is not supported: the solver does not "
398
+ "implement parameter sensitivities (they would be silently zero). "
399
+ "Use jacobian_mode='numerical'."
400
+ )
401
+
402
+ _verbose("Fitting...", end="")
403
+
404
+ if index is None:
405
+ index = model.get_obs_index()
406
+
407
+ new_model = model.copy_without_results()
408
+
409
+ # Use the current values as the initial estimate
410
+ segment_list = new_model.get_parameter_list()
411
+ # Assume that any segments of zero length are the first segment
412
+ # and exclude these from the estimate of x0
413
+ # TODO: check that zero_segments are indeed ST_min
414
+ non_zero_segments = segment_list > 0
415
+ x0 = np.log(segment_list[non_zero_segments])
416
+ xmin = np.ones_like(x0) * np.log(new_model.options["ST_smallest_segment"])
417
+ xmax = np.ones_like(x0) * np.log(new_model.options["ST_largest_segment"])
418
+ if any((x0 < xmin) | (x0 > xmax)):
419
+ print(f"A segment size is out of bounds: {np.exp(x0)}")
420
+ nseg_x0 = len(x0)
421
+ if include_C_old:
422
+ x0 = np.r_[x0, [new_model.solute_parameters[sol]["C_old"] for sol in new_model._solorder]]
423
+ xmin = np.r_[xmin, [-np.inf for sol in new_model._solorder]]
424
+ xmax = np.r_[xmax, [np.inf for sol in new_model._solorder]]
425
+
426
+ # Construct an index into the the columns returned by get_jacobian() that we wish to keep
427
+ keep_jac_columns = np.ones(len(segment_list) + new_model._numsol) == 1
428
+ keep_jac_columns[: len(segment_list)] = non_zero_segments
429
+
430
+ new_model.x_prev = None
431
+
432
+ def update_parameters(x):
433
+ if np.any(x > xmax) or np.any(x < xmin):
434
+ return False
435
+
436
+ if new_model.x_prev is not None and np.all(x == new_model.x_prev):
437
+ return False
438
+
439
+ else:
440
+ # _verbose(x)
441
+ new_segment_list = np.zeros_like(segment_list)
442
+ new_segment_list[non_zero_segments] = np.exp(x[:nseg_x0])
443
+ new_model.update_from_parameter_list(new_segment_list)
444
+
445
+ # optionally update the C_old parameter
446
+ if include_C_old:
447
+ for isol, sol in enumerate(new_model._solorder):
448
+ new_model.solute_parameters[sol]["C_old"] = x[-new_model._numsol + isol]
449
+ new_model.x_prev = x
450
+ return True
451
+
452
+ def f(x):
453
+ """return residuals given parameters x"""
454
+
455
+ update_parameters(x)
456
+ new_model.run()
457
+
458
+ # optionally do some plotting
459
+ if learn_fun is not None:
460
+ learn_fun(new_model)
461
+
462
+ residuals = new_model.get_residuals()[index]
463
+ return residuals
464
+
465
+ def jac(x):
466
+ """return the jacobian given parameters x"""
467
+
468
+ if update_parameters(x):
469
+ new_model.run()
470
+
471
+ jacobian = new_model.get_jacobian()[index, :]
472
+ return jacobian[:, keep_jac_columns]
473
+
474
+ # use the scipy.optimize.least_square package
475
+ if jacobian_mode == "analytical":
476
+ OptimizeResult = least_squares(fun=f, x0=x0, jac=jac, verbose=2, bounds=(xmin, xmax))
477
+ elif jacobian_mode == "numerical":
478
+ OptimizeResult = least_squares(fun=f, x0=x0, verbose=0, bounds=(xmin, xmax))
479
+
480
+ xopt = OptimizeResult.x
481
+ update_parameters(xopt)
482
+ new_rmse = np.sqrt(np.mean(OptimizeResult.fun**2))
483
+ new_model.rmse = new_rmse
484
+
485
+ _verbose("done.")
486
+ return new_model, new_rmse
487
+
488
+
489
+ def cross_validation_rmse(model, index=None, n_splits=3, **kwargs):
490
+ try:
491
+ from sklearn.model_selection import KFold
492
+ except ImportError as err:
493
+ raise ImportError(
494
+ "scikit-learn is required for k-fold cross-validation. "
495
+ "Install it with `pip install mesas[estimation]` (or `pip install scikit-learn`)."
496
+ ) from err
497
+ if index is None:
498
+ index = model.get_obs_index()
499
+ kf = KFold(n_splits=n_splits)
500
+ rmse_cv_list = []
501
+ _verbose("Getting rmse from k-fold cross validation")
502
+ iter = 0
503
+ for train, test in kf.split(index):
504
+ iter += 1
505
+ _verbose(f"-- iteration {iter} of {n_splits} ", end="")
506
+ new_model_cv, _ = fit_model(model, index=index[train], **kwargs)
507
+ rmse_cv_list.append(np.sqrt(np.mean(new_model_cv.get_residuals()[index[test]] ** 2)))
508
+ # _verbose(f' rmse_cv = {rmse_cv_list}')
509
+ # rmse_cv_list.remove(max(rmse_cv_list))
510
+ # rmse_cv_list.remove(min(rmse_cv_list))
511
+ # rmse_cv = np.sqrt(np.mean(np.sqrt(rmse_cv_list))**2)
512
+ rmse_cv = np.array(rmse_cv_list)
513
+ model.rmse_cv = rmse_cv
514
+ return rmse_cv
mesas/sas/__init__.py ADDED
File without changes