innovate 0.1.0__tar.gz
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.
- innovate-0.1.0/PKG-INFO +60 -0
- innovate-0.1.0/README.md +43 -0
- innovate-0.1.0/pyproject.toml +30 -0
- innovate-0.1.0/setup.cfg +4 -0
- innovate-0.1.0/src/innovate/__init__.py +0 -0
- innovate-0.1.0/src/innovate/backend.py +34 -0
- innovate-0.1.0/src/innovate/backends/jax_backend.py +44 -0
- innovate-0.1.0/src/innovate/backends/numpy_backend.py +49 -0
- innovate-0.1.0/src/innovate/fitters/__init__.py +0 -0
- innovate-0.1.0/src/innovate/fitters/batched_fitter.py +55 -0
- innovate-0.1.0/src/innovate/fitters/bootstrap_fitter.py +67 -0
- innovate-0.1.0/src/innovate/fitters/jax_fitter.py +37 -0
- innovate-0.1.0/src/innovate/fitters/mom_fitter.py +93 -0
- innovate-0.1.0/src/innovate/fitters/scipy_fitter.py +86 -0
- innovate-0.1.0/src/innovate/models/base.py +34 -0
- innovate-0.1.0/src/innovate/models/bass.py +85 -0
- innovate-0.1.0/src/innovate/models/competition.py +197 -0
- innovate-0.1.0/src/innovate/models/gompertz.py +73 -0
- innovate-0.1.0/src/innovate/models/logistic.py +97 -0
- innovate-0.1.0/src/innovate/plots.py +63 -0
- innovate-0.1.0/src/innovate/utils/__init__.py +0 -0
- innovate-0.1.0/src/innovate/utils/categorization.py +43 -0
- innovate-0.1.0/src/innovate/utils/metrics.py +50 -0
- innovate-0.1.0/src/innovate/utils/model_evaluation.py +81 -0
- innovate-0.1.0/src/innovate/utils/preprocessing.py +82 -0
- innovate-0.1.0/src/innovate.egg-info/PKG-INFO +60 -0
- innovate-0.1.0/src/innovate.egg-info/SOURCES.txt +33 -0
- innovate-0.1.0/src/innovate.egg-info/dependency_links.txt +1 -0
- innovate-0.1.0/src/innovate.egg-info/requires.txt +9 -0
- innovate-0.1.0/src/innovate.egg-info/top_level.txt +1 -0
- innovate-0.1.0/tests/test_batched_fitter.py +74 -0
- innovate-0.1.0/tests/test_categorization.py +25 -0
- innovate-0.1.0/tests/test_fitters.py +69 -0
- innovate-0.1.0/tests/test_models.py +177 -0
- innovate-0.1.0/tests/test_preprocessing.py +30 -0
innovate-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: innovate
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Python library for simplifying innovation/policy diffusion modelling.
|
|
5
|
+
Author-email: Your Name <your.email@example.com>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Requires-Python: >=3.8
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: numpy
|
|
10
|
+
Requires-Dist: scipy
|
|
11
|
+
Requires-Dist: pandas
|
|
12
|
+
Requires-Dist: statsmodels
|
|
13
|
+
Provides-Extra: jax
|
|
14
|
+
Requires-Dist: jax; extra == "jax"
|
|
15
|
+
Requires-Dist: jaxopt; extra == "jax"
|
|
16
|
+
Requires-Dist: diffrax; extra == "jax"
|
|
17
|
+
|
|
18
|
+
# innovate
|
|
19
|
+
|
|
20
|
+
A Python library for simplifying innovation/policy diffusion modelling.
|
|
21
|
+
|
|
22
|
+
This library aims to provide a flexible and robust framework for modeling the diffusion of innovations and policies across various domains, from advanced health technologies (its origin in genetic and genomic testing) to marketing, economics, and policy research.
|
|
23
|
+
|
|
24
|
+
## Core Features (Initial Release)
|
|
25
|
+
|
|
26
|
+
* **Diffusion Models**: Implementations of classic S-curve models like Gompertz, Logistic, and Bass (closed-form solutions).
|
|
27
|
+
* **Generic Competition-Diffusion Framework**: A flexible structure to model multi-product or multi-policy diffusion, allowing for intrinsic adoption rates, market sizes, and interaction matrices (within- and cross-imitation).
|
|
28
|
+
* **Data Handling**: Pandas-friendly API for time-series data with datetime indices.
|
|
29
|
+
* **Estimation**: Initial fitting capabilities using tried-and-true libraries like SciPy for nonlinear least squares.
|
|
30
|
+
* **Seasonality & Dispersion Handling**: Practical strategies for dealing with noisy, seasonal, or over-dispersed raw adoption data, including two-stage decomposition (e.g., using STL) and options for more integrated fitting approaches.
|
|
31
|
+
|
|
32
|
+
## Roadmap
|
|
33
|
+
|
|
34
|
+
The `innovate` library is designed for phased development, with a clear path towards advanced features and XLA-accelerated performance. See the [roadmap.md](roadmap.md) for detailed plans.
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
More information coming soon!
|
|
39
|
+
|
|
40
|
+
## Benchmarks
|
|
41
|
+
|
|
42
|
+
Here are some initial benchmarks comparing the performance of the `ScipyFitter` (using the NumPy backend) and the `JaxFitter` (using the JAX backend).
|
|
43
|
+
|
|
44
|
+
**Single Fit (100 samples, 1 dataset):**
|
|
45
|
+
* `ScipyFitter`: ~0.02s
|
|
46
|
+
* `JaxFitter`: ~5.7s
|
|
47
|
+
|
|
48
|
+
**Batched Fit (100 samples, 10 datasets):**
|
|
49
|
+
* `BatchedFitter` (NumPy): ~0.06s
|
|
50
|
+
* `BatchedFitter` (JAX): ~15.5s
|
|
51
|
+
|
|
52
|
+
**Note:** For small datasets, the `JaxFitter` is significantly slower than the `ScipyFitter` due to the overhead of JIT compilation. However, for larger datasets and in batched scenarios, the JAX backend is expected to be significantly faster. These benchmarks will be updated as the library evolves.
|
|
53
|
+
|
|
54
|
+
## Usage
|
|
55
|
+
|
|
56
|
+
Examples and tutorials will be provided to demonstrate how to use the library for various modeling scenarios.
|
|
57
|
+
|
|
58
|
+
## License
|
|
59
|
+
|
|
60
|
+
This project is licensed under the Apache 2.0 License.
|
innovate-0.1.0/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# innovate
|
|
2
|
+
|
|
3
|
+
A Python library for simplifying innovation/policy diffusion modelling.
|
|
4
|
+
|
|
5
|
+
This library aims to provide a flexible and robust framework for modeling the diffusion of innovations and policies across various domains, from advanced health technologies (its origin in genetic and genomic testing) to marketing, economics, and policy research.
|
|
6
|
+
|
|
7
|
+
## Core Features (Initial Release)
|
|
8
|
+
|
|
9
|
+
* **Diffusion Models**: Implementations of classic S-curve models like Gompertz, Logistic, and Bass (closed-form solutions).
|
|
10
|
+
* **Generic Competition-Diffusion Framework**: A flexible structure to model multi-product or multi-policy diffusion, allowing for intrinsic adoption rates, market sizes, and interaction matrices (within- and cross-imitation).
|
|
11
|
+
* **Data Handling**: Pandas-friendly API for time-series data with datetime indices.
|
|
12
|
+
* **Estimation**: Initial fitting capabilities using tried-and-true libraries like SciPy for nonlinear least squares.
|
|
13
|
+
* **Seasonality & Dispersion Handling**: Practical strategies for dealing with noisy, seasonal, or over-dispersed raw adoption data, including two-stage decomposition (e.g., using STL) and options for more integrated fitting approaches.
|
|
14
|
+
|
|
15
|
+
## Roadmap
|
|
16
|
+
|
|
17
|
+
The `innovate` library is designed for phased development, with a clear path towards advanced features and XLA-accelerated performance. See the [roadmap.md](roadmap.md) for detailed plans.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
More information coming soon!
|
|
22
|
+
|
|
23
|
+
## Benchmarks
|
|
24
|
+
|
|
25
|
+
Here are some initial benchmarks comparing the performance of the `ScipyFitter` (using the NumPy backend) and the `JaxFitter` (using the JAX backend).
|
|
26
|
+
|
|
27
|
+
**Single Fit (100 samples, 1 dataset):**
|
|
28
|
+
* `ScipyFitter`: ~0.02s
|
|
29
|
+
* `JaxFitter`: ~5.7s
|
|
30
|
+
|
|
31
|
+
**Batched Fit (100 samples, 10 datasets):**
|
|
32
|
+
* `BatchedFitter` (NumPy): ~0.06s
|
|
33
|
+
* `BatchedFitter` (JAX): ~15.5s
|
|
34
|
+
|
|
35
|
+
**Note:** For small datasets, the `JaxFitter` is significantly slower than the `ScipyFitter` due to the overhead of JIT compilation. However, for larger datasets and in batched scenarios, the JAX backend is expected to be significantly faster. These benchmarks will be updated as the library evolves.
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
Examples and tutorials will be provided to demonstrate how to use the library for various modeling scenarios.
|
|
40
|
+
|
|
41
|
+
## License
|
|
42
|
+
|
|
43
|
+
This project is licensed under the Apache 2.0 License.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "innovate"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A Python library for simplifying innovation/policy diffusion modelling."
|
|
5
|
+
authors = [
|
|
6
|
+
{ name = "Your Name", email = "your.email@example.com" }
|
|
7
|
+
]
|
|
8
|
+
dependencies = [
|
|
9
|
+
"numpy",
|
|
10
|
+
"scipy",
|
|
11
|
+
"pandas",
|
|
12
|
+
"statsmodels"
|
|
13
|
+
]
|
|
14
|
+
requires-python = ">=3.8"
|
|
15
|
+
readme = "README.md"
|
|
16
|
+
license = "Apache-2.0"
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["setuptools>=61.0"]
|
|
20
|
+
build-backend = "setuptools.build_meta"
|
|
21
|
+
|
|
22
|
+
[tool.setuptools.packages.find]
|
|
23
|
+
where = ["src"]
|
|
24
|
+
|
|
25
|
+
[tool.pytest.ini_options]
|
|
26
|
+
pythonpath = ["src"]
|
|
27
|
+
addopts = "--import-mode=importlib"
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
jax = ["jax", "jaxopt", "diffrax"]
|
innovate-0.1.0/setup.cfg
ADDED
|
File without changes
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from typing import Protocol, Type, Sequence, Dict
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
|
|
4
|
+
# Define a type alias for array-like objects
|
|
5
|
+
ArrayLike = Sequence[float]
|
|
6
|
+
|
|
7
|
+
class Backend(Protocol):
|
|
8
|
+
def array(self, data: ArrayLike) -> ArrayLike: ...
|
|
9
|
+
def exp(self, x: ArrayLike) -> ArrayLike: ...
|
|
10
|
+
def power(self, base: ArrayLike, exp: ArrayLike) -> ArrayLike: ...
|
|
11
|
+
def solve_ode(self, f, y0: ArrayLike, t: ArrayLike) -> ArrayLike: ...
|
|
12
|
+
def stack(self, arrays: Sequence[ArrayLike]) -> ArrayLike: ...
|
|
13
|
+
def matmul(self, a: ArrayLike, b: ArrayLike) -> ArrayLike: ...
|
|
14
|
+
def zeros(self, shape: Sequence[int]) -> ArrayLike: ...
|
|
15
|
+
def sum(self, x: ArrayLike) -> float: ...
|
|
16
|
+
def mean(self, x: ArrayLike) -> float: ...
|
|
17
|
+
def where(self, condition: ArrayLike, x: ArrayLike, y: ArrayLike) -> ArrayLike: ...
|
|
18
|
+
|
|
19
|
+
# Default backend (will be set to NumPyBackend initially)
|
|
20
|
+
current_backend: Backend = None
|
|
21
|
+
|
|
22
|
+
def use_backend(backend_name: str):
|
|
23
|
+
global current_backend
|
|
24
|
+
if backend_name == "numpy":
|
|
25
|
+
from .backends.numpy_backend import NumPyBackend
|
|
26
|
+
current_backend = NumPyBackend()
|
|
27
|
+
elif backend_name == "jax":
|
|
28
|
+
from .backends.jax_backend import JaxBackend
|
|
29
|
+
current_backend = JaxBackend()
|
|
30
|
+
else:
|
|
31
|
+
raise ValueError(f"Unknown backend: {backend_name}")
|
|
32
|
+
|
|
33
|
+
# Initialize with NumPy backend by default
|
|
34
|
+
use_backend("numpy")
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import jax.numpy as jnp
|
|
2
|
+
from diffrax import diffeqsolve, ODETerm, Tsit5
|
|
3
|
+
from typing import Sequence
|
|
4
|
+
|
|
5
|
+
class JaxBackend:
|
|
6
|
+
def array(self, data: Sequence[float]) -> jnp.ndarray:
|
|
7
|
+
return jnp.asarray(data)
|
|
8
|
+
|
|
9
|
+
def exp(self, x: jnp.ndarray) -> jnp.ndarray:
|
|
10
|
+
return jnp.exp(x)
|
|
11
|
+
|
|
12
|
+
def power(self, base: jnp.ndarray, exp: jnp.ndarray) -> jnp.ndarray:
|
|
13
|
+
return jnp.power(base, exp)
|
|
14
|
+
|
|
15
|
+
def solve_ode(self, f, y0: Sequence[float], t: Sequence[float]) -> jnp.ndarray:
|
|
16
|
+
term = ODETerm(f)
|
|
17
|
+
sol = diffeqsolve(term, Tsit5(), t[0], t[-1], t, y0)
|
|
18
|
+
return sol.ys
|
|
19
|
+
|
|
20
|
+
def stack(self, arrays: Sequence[jnp.ndarray]) -> jnp.ndarray:
|
|
21
|
+
return jnp.stack(arrays)
|
|
22
|
+
|
|
23
|
+
def matmul(self, a: jnp.ndarray, b: jnp.ndarray) -> jnp.ndarray:
|
|
24
|
+
return jnp.matmul(a, b)
|
|
25
|
+
|
|
26
|
+
def zeros(self, shape: Sequence[int]) -> jnp.ndarray:
|
|
27
|
+
return jnp.zeros(shape)
|
|
28
|
+
|
|
29
|
+
def sum(self, x: jnp.ndarray) -> float:
|
|
30
|
+
return jnp.sum(x)
|
|
31
|
+
|
|
32
|
+
def mean(self, x: jnp.ndarray) -> float:
|
|
33
|
+
return jnp.mean(x)
|
|
34
|
+
|
|
35
|
+
def where(self, condition: jnp.ndarray, x: jnp.ndarray, y: jnp.ndarray) -> jnp.ndarray:
|
|
36
|
+
return jnp.where(condition, x, y)
|
|
37
|
+
|
|
38
|
+
def jit(self, f):
|
|
39
|
+
from jax import jit
|
|
40
|
+
return jit(f)
|
|
41
|
+
|
|
42
|
+
def vmap(self, f):
|
|
43
|
+
from jax import vmap
|
|
44
|
+
return vmap(f)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from scipy.integrate import odeint
|
|
3
|
+
from typing import Sequence
|
|
4
|
+
|
|
5
|
+
class NumPyBackend:
|
|
6
|
+
def array(self, data):
|
|
7
|
+
return np.asarray(data)
|
|
8
|
+
|
|
9
|
+
exp = np.exp
|
|
10
|
+
power = np.power
|
|
11
|
+
def sum(self, a, axis=None, dtype=None, out=None, keepdims=False, initial=None, where=True):
|
|
12
|
+
return np.sum(a, axis=axis, dtype=dtype, out=out, keepdims=keepdims, initial=initial, where=where)
|
|
13
|
+
|
|
14
|
+
def mean(self, a, axis=None, dtype=None, out=None, keepdims=False, *, where=True):
|
|
15
|
+
return np.mean(a, axis=axis, dtype=dtype, out=out, keepdims=keepdims, where=where)
|
|
16
|
+
|
|
17
|
+
def where(self, condition, x, y):
|
|
18
|
+
return np.where(condition, x, y)
|
|
19
|
+
|
|
20
|
+
def solve_ode(self, f, y0: Sequence[float], t: Sequence[float]) -> np.ndarray:
|
|
21
|
+
# scipy.integrate.odeint expects y0 as a 1D array and t as a 1D array
|
|
22
|
+
# The function f should take (y, t, *args) as arguments
|
|
23
|
+
# We need to adapt the signature of f if it expects (t, y, *args)
|
|
24
|
+
# For now, assuming f takes (y, t) as per common scipy usage
|
|
25
|
+
sol = odeint(f, y0, t)
|
|
26
|
+
return sol
|
|
27
|
+
|
|
28
|
+
def stack(self, arrays: Sequence[np.ndarray]) -> np.ndarray:
|
|
29
|
+
return np.stack(arrays)
|
|
30
|
+
|
|
31
|
+
def matmul(self, a: np.ndarray, b: np.ndarray) -> np.ndarray:
|
|
32
|
+
return np.matmul(a, b)
|
|
33
|
+
|
|
34
|
+
def zeros(self, shape: Sequence[int]) -> np.ndarray:
|
|
35
|
+
return np.zeros(shape)
|
|
36
|
+
|
|
37
|
+
def max(self, x: np.ndarray) -> float:
|
|
38
|
+
return np.max(x)
|
|
39
|
+
|
|
40
|
+
def median(self, x: np.ndarray) -> float:
|
|
41
|
+
return np.median(x)
|
|
42
|
+
|
|
43
|
+
def jit(self, f):
|
|
44
|
+
return f
|
|
45
|
+
|
|
46
|
+
def vmap(self, f):
|
|
47
|
+
def mapped_f(params, t_batched):
|
|
48
|
+
return np.array([f(p, t) for p, t in zip(params, t_batched)])
|
|
49
|
+
return mapped_f
|
|
File without changes
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from typing import Sequence, List
|
|
2
|
+
from ..models.base import DiffusionModel
|
|
3
|
+
from ..backend import current_backend as B
|
|
4
|
+
|
|
5
|
+
class BatchedFitter:
|
|
6
|
+
"""A fitter class for fitting a model to multiple datasets in a batch."""
|
|
7
|
+
|
|
8
|
+
def __init__(self, model: DiffusionModel, fitter):
|
|
9
|
+
self.model = model
|
|
10
|
+
self.fitter = fitter
|
|
11
|
+
self.fitted_params = None
|
|
12
|
+
|
|
13
|
+
def fit(self, t_batched: Sequence[Sequence[float]], y_batched: Sequence[Sequence[float]]):
|
|
14
|
+
"""
|
|
15
|
+
Fits the model to a batch of datasets.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
t_batched: A sequence of time sequences.
|
|
19
|
+
y_batched: A sequence of adoption sequences.
|
|
20
|
+
"""
|
|
21
|
+
if len(t_batched) != len(y_batched):
|
|
22
|
+
raise ValueError("The number of time sequences and adoption sequences must be the same.")
|
|
23
|
+
|
|
24
|
+
params_list = []
|
|
25
|
+
for t, y in zip(t_batched, y_batched):
|
|
26
|
+
model_instance = type(self.model)()
|
|
27
|
+
self.fitter.fit(model_instance, t, y)
|
|
28
|
+
params_list.append(list(model_instance.params_.values()))
|
|
29
|
+
|
|
30
|
+
self.fitted_params = B.array(params_list)
|
|
31
|
+
return self.fitted_params
|
|
32
|
+
|
|
33
|
+
def predict(self, t_batched: Sequence[Sequence[float]]):
|
|
34
|
+
"""
|
|
35
|
+
Makes predictions for a batch of datasets.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
t_batched: A sequence of time sequences.
|
|
39
|
+
"""
|
|
40
|
+
if self.fitted_params is None:
|
|
41
|
+
raise RuntimeError("Model has not been fitted yet. Call .fit() first.")
|
|
42
|
+
|
|
43
|
+
def predict_single(params, t):
|
|
44
|
+
model_instance = type(self.model)()
|
|
45
|
+
# This is a bit of a hack, we should make this more generic
|
|
46
|
+
if model_instance.__class__.__name__ == "LogisticModel":
|
|
47
|
+
return model_instance._logistic_cumulative(t, params[0], params[1], params[2])
|
|
48
|
+
elif model_instance.__class__.__name__ == "BassModel":
|
|
49
|
+
return model_instance._bass_cumulative(t, params[0], params[1], params[2])
|
|
50
|
+
elif model_instance.__class__.__name__ == "GompertzModel":
|
|
51
|
+
return model_instance._gompertz_cumulative(t, params[0], params[1], params[2])
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
vmap_predict = B.vmap(predict_single)
|
|
55
|
+
return vmap_predict(self.fitted_params, B.array(t_batched))
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from typing import Sequence, Dict, List, Any
|
|
2
|
+
import numpy as np
|
|
3
|
+
from ..models.base import DiffusionModel
|
|
4
|
+
|
|
5
|
+
class BootstrapFitter:
|
|
6
|
+
"""A fitter class that uses bootstrapping to estimate parameter uncertainty."""
|
|
7
|
+
|
|
8
|
+
def __init__(self, n_bootstraps: int = 100):
|
|
9
|
+
self.n_bootstraps = n_bootstraps
|
|
10
|
+
self.bootstrapped_params: List[Dict[str, float]] = []
|
|
11
|
+
|
|
12
|
+
def fit(self, model: DiffusionModel, t: Sequence[float], y: Sequence[float]) -> None:
|
|
13
|
+
t_arr = np.array(t)
|
|
14
|
+
y_arr = np.array(y)
|
|
15
|
+
n_samples = len(t_arr)
|
|
16
|
+
|
|
17
|
+
for _ in range(self.n_bootstraps):
|
|
18
|
+
# Resample data with replacement
|
|
19
|
+
indices = np.random.choice(n_samples, n_samples, replace=True)
|
|
20
|
+
t_resampled = t_arr[indices]
|
|
21
|
+
y_resampled = y_arr[indices]
|
|
22
|
+
|
|
23
|
+
# Create a new model instance for each bootstrap iteration
|
|
24
|
+
# This is important to avoid parameter contamination between iterations
|
|
25
|
+
boot_model = type(model)()
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
boot_model.fit(t_resampled, y_resampled)
|
|
29
|
+
self.bootstrapped_params.append(boot_model.params_)
|
|
30
|
+
except RuntimeError as e:
|
|
31
|
+
# Handle cases where fitting might fail for a resampled dataset
|
|
32
|
+
print(f"Warning: Fitting failed for a bootstrap sample: {e}")
|
|
33
|
+
continue
|
|
34
|
+
|
|
35
|
+
def get_parameter_estimates(self) -> Dict[str, List[float]]:
|
|
36
|
+
"""Returns a dictionary of parameter names to lists of bootstrapped values."""
|
|
37
|
+
if not self.bootstrapped_params:
|
|
38
|
+
return {}
|
|
39
|
+
|
|
40
|
+
# Assuming all models have the same parameter names
|
|
41
|
+
param_names = self.bootstrapped_params[0].keys()
|
|
42
|
+
estimates = {name: [] for name in param_names}
|
|
43
|
+
|
|
44
|
+
for params_dict in self.bootstrapped_params:
|
|
45
|
+
for name, value in params_dict.items():
|
|
46
|
+
estimates[name].append(value)
|
|
47
|
+
return estimates
|
|
48
|
+
|
|
49
|
+
def get_confidence_intervals(self, alpha: float = 0.05) -> Dict[str, Dict[str, float]]:
|
|
50
|
+
"""Returns confidence intervals for each parameter."""
|
|
51
|
+
estimates = self.get_parameter_estimates()
|
|
52
|
+
cis = {}
|
|
53
|
+
for name, values in estimates.items():
|
|
54
|
+
if values:
|
|
55
|
+
lower = np.percentile(values, (alpha / 2) * 100)
|
|
56
|
+
upper = np.percentile(values, (1 - alpha / 2) * 100)
|
|
57
|
+
cis[name] = {"lower": float(lower), "upper": float(upper)}
|
|
58
|
+
return cis
|
|
59
|
+
|
|
60
|
+
def get_standard_errors(self) -> Dict[str, float]:
|
|
61
|
+
"""Returns standard errors for each parameter."""
|
|
62
|
+
estimates = self.get_parameter_estimates()
|
|
63
|
+
ses = {}
|
|
64
|
+
for name, values in estimates.items():
|
|
65
|
+
if values:
|
|
66
|
+
ses[name] = float(np.std(values))
|
|
67
|
+
return ses
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import jax
|
|
2
|
+
import jax.numpy as jnp
|
|
3
|
+
from jaxopt import LBFGS
|
|
4
|
+
from typing import Sequence, Dict
|
|
5
|
+
|
|
6
|
+
class JaxFitter:
|
|
7
|
+
"""A fitter class that will use JAX for model estimation (Phase 2)."""
|
|
8
|
+
|
|
9
|
+
def fit(self, model, t: Sequence[float], y: Sequence[float], **kwargs) -> Dict[str, float]:
|
|
10
|
+
t_arr = jnp.asarray(t)
|
|
11
|
+
y_arr = jnp.asarray(y)
|
|
12
|
+
|
|
13
|
+
from heartflow.backend import current_backend, use_backend
|
|
14
|
+
original_backend = current_backend
|
|
15
|
+
use_backend("jax")
|
|
16
|
+
|
|
17
|
+
def _logistic_cumulative(t, L, k, x0):
|
|
18
|
+
from heartflow.backend import current_backend as B
|
|
19
|
+
return L / (1 + B.exp(-k * (t - x0)))
|
|
20
|
+
|
|
21
|
+
model._logistic_cumulative = _logistic_cumulative
|
|
22
|
+
|
|
23
|
+
def loss_fn(params_array):
|
|
24
|
+
# Temporarily set model parameters for prediction within the loss function
|
|
25
|
+
predictions = model._logistic_cumulative(t_arr, params_array[0], params_array[1], params_array[2])
|
|
26
|
+
return jnp.sum((y_arr - predictions) ** 2)
|
|
27
|
+
|
|
28
|
+
initial_params = model._get_initial_params_as_array(t, y)
|
|
29
|
+
|
|
30
|
+
opt = LBFGS(fun=loss_fn, **kwargs)
|
|
31
|
+
sol = opt.run(init_params=initial_params)
|
|
32
|
+
|
|
33
|
+
model._L, model._k, model._x0 = sol.params[0], sol.params[1], sol.params[2]
|
|
34
|
+
|
|
35
|
+
use_backend(original_backend.__class__.__name__.lower().replace('backend', '')) # Restore original backend
|
|
36
|
+
|
|
37
|
+
return model.params_
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
from typing import Sequence, Tuple
|
|
4
|
+
|
|
5
|
+
def estimate_bass_mom(t: Sequence[float], y: Sequence[float]) -> Tuple[float, float, float]:
|
|
6
|
+
"""
|
|
7
|
+
Estimates the parameters (p, q, m) of the Bass Diffusion Model using the Method of Moments.
|
|
8
|
+
This implementation uses a linear regression approach based on incremental adoptions.
|
|
9
|
+
|
|
10
|
+
Args:
|
|
11
|
+
t: A sequence of time points.
|
|
12
|
+
y: A sequence of cumulative adoptions corresponding to the time points.
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
A tuple (p, q, m) representing the estimated parameters.
|
|
16
|
+
"""
|
|
17
|
+
if len(t) != len(y) or len(t) < 3:
|
|
18
|
+
raise ValueError("Input sequences t and y must have the same length and at least 3 data points.")
|
|
19
|
+
|
|
20
|
+
# Convert to pandas Series for easier manipulation
|
|
21
|
+
y_series = pd.Series(y, index=t)
|
|
22
|
+
|
|
23
|
+
# Calculate incremental adoptions (x_t)
|
|
24
|
+
# The first incremental adoption is y[0] if y starts from 0, or y[1]-y[0] if y[0] is the first cumulative.
|
|
25
|
+
# For simplicity, let's use diff() on the series.
|
|
26
|
+
incremental_adoptions = y_series.diff().fillna(y_series.iloc[0])
|
|
27
|
+
|
|
28
|
+
# Create lagged cumulative adoptions (y_{t-1})
|
|
29
|
+
lagged_cumulative_adoptions = y_series.shift(1).fillna(0)
|
|
30
|
+
|
|
31
|
+
# Prepare data for linear regression: x_t = a + b * y_{t-1} + c * y_{t-1}^2
|
|
32
|
+
# We need to exclude the first point where lagged_cumulative_adoptions is 0, as it's not a true lagged value.
|
|
33
|
+
data_for_reg = pd.DataFrame({
|
|
34
|
+
'x_t': incremental_adoptions,
|
|
35
|
+
'y_t_minus_1': lagged_cumulative_adoptions
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
# Remove the first row as y_t_minus_1 is 0 (or NaN if using dropna)
|
|
39
|
+
data_for_reg = data_for_reg.iloc[1:].dropna()
|
|
40
|
+
|
|
41
|
+
if len(data_for_reg) < 3:
|
|
42
|
+
raise ValueError("Not enough valid data points for Bass MoM estimation after preprocessing.")
|
|
43
|
+
|
|
44
|
+
X = pd.DataFrame({
|
|
45
|
+
'intercept': 1,
|
|
46
|
+
'y_t_minus_1': data_for_reg['y_t_minus_1'],
|
|
47
|
+
'y_t_minus_1_sq': data_for_reg['y_t_minus_1']**2
|
|
48
|
+
})
|
|
49
|
+
y_reg = data_for_reg['x_t']
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
beta = np.linalg.lstsq(X, y_reg, rcond=None)[0]
|
|
53
|
+
except np.linalg.LinAlgError as e:
|
|
54
|
+
raise RuntimeError(f"Linear regression for Bass MoM failed: {e}. Check data for collinearity or insufficient variation.")
|
|
55
|
+
|
|
56
|
+
a, b, c = beta[0], beta[1], beta[2]
|
|
57
|
+
|
|
58
|
+
# Solve for p, q, m
|
|
59
|
+
# From x_t = a + b * y_{t-1} + c * y_{t-1}^2
|
|
60
|
+
# And Bass model incremental form: x_t = p*m + (q-p)*y_{t-1} - (q/m)*y_{t-1}^2
|
|
61
|
+
# Comparing coefficients:
|
|
62
|
+
# c = -q/m => q = -c*m
|
|
63
|
+
# b = q - p
|
|
64
|
+
# a = p*m
|
|
65
|
+
|
|
66
|
+
# Rearrange to a quadratic equation for m: c*m^2 + b*m + a = 0
|
|
67
|
+
discriminant = b**2 - 4 * a * c
|
|
68
|
+
|
|
69
|
+
if discriminant < 0:
|
|
70
|
+
raise ValueError("Discriminant is negative, no real solution for m. Bass MoM estimation failed. Data might not fit Bass model assumptions well.")
|
|
71
|
+
|
|
72
|
+
m1 = (-b + np.sqrt(discriminant)) / (2 * c)
|
|
73
|
+
m2 = (-b - np.sqrt(discriminant)) / (2 * c)
|
|
74
|
+
|
|
75
|
+
# Choose the positive and meaningful m (market potential should be positive)
|
|
76
|
+
# And m should be greater than or equal to the maximum observed cumulative adoption.
|
|
77
|
+
m_candidates = [val for val in [m1, m2] if val > 0 and val >= np.max(y)]
|
|
78
|
+
|
|
79
|
+
if not m_candidates:
|
|
80
|
+
raise ValueError("No valid positive market potential (m) found that is greater than or equal to max observed adoption. Bass MoM estimation failed.")
|
|
81
|
+
|
|
82
|
+
# If there are two valid candidates, typically the larger one is chosen for m.
|
|
83
|
+
m = max(m_candidates)
|
|
84
|
+
|
|
85
|
+
# Calculate q and p
|
|
86
|
+
q = -c * m
|
|
87
|
+
p = a / m
|
|
88
|
+
|
|
89
|
+
# Ensure p and q are positive, as per Bass model assumptions
|
|
90
|
+
if p <= 0 or q <= 0:
|
|
91
|
+
raise ValueError(f"Estimated p ({p}) or q ({q}) is not positive. Bass MoM estimation failed. Data might not fit Bass model assumptions well.")
|
|
92
|
+
|
|
93
|
+
return p, q, m
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from typing import Sequence, Dict, Callable, Any, Self
|
|
2
|
+
import numpy as np
|
|
3
|
+
from scipy.optimize import curve_fit
|
|
4
|
+
from ..models.base import DiffusionModel
|
|
5
|
+
from ..models.competition import MultiProductDiffusionModel
|
|
6
|
+
|
|
7
|
+
class ScipyFitter:
|
|
8
|
+
"""A fitter class that uses SciPy's curve_fit for model estimation."""
|
|
9
|
+
|
|
10
|
+
def __init__(self):
|
|
11
|
+
self.params_: Dict[str, float] = None
|
|
12
|
+
|
|
13
|
+
def fit(self, model: DiffusionModel, t: Sequence[float], y: Sequence[float], **kwargs) -> Self:
|
|
14
|
+
"""
|
|
15
|
+
Fits a DiffusionModel instance using scipy.optimize.curve_fit.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
model: An instance of a DiffusionModel (e.g., BassModel, GompertzModel, LogisticModel).
|
|
19
|
+
t: Time points (independent variable).
|
|
20
|
+
y: Observed adoption data (dependent variable).
|
|
21
|
+
kwargs: Additional keyword arguments to pass to scipy.optimize.curve_fit.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
A dictionary of the fitted parameters.
|
|
25
|
+
|
|
26
|
+
Raises:
|
|
27
|
+
RuntimeError: If fitting fails.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
t_arr = np.array(t)
|
|
31
|
+
y_arr = np.array(y)
|
|
32
|
+
|
|
33
|
+
# For MultiProductDiffusionModel, y will be a flattened array of multiple products
|
|
34
|
+
# We need to handle this by reshaping the prediction from the model.
|
|
35
|
+
if isinstance(model, MultiProductDiffusionModel):
|
|
36
|
+
# The objective function for curve_fit needs to take t and then the parameters
|
|
37
|
+
# and return a flattened array of predictions for all products.
|
|
38
|
+
|
|
39
|
+
# We need to define the parameters to be fitted for MultiProductDiffusionModel.
|
|
40
|
+
# For now, let's assume we are fitting p, Q, m. This will require careful
|
|
41
|
+
# flattening and unflattening of parameters.
|
|
42
|
+
|
|
43
|
+
# This is a more complex scenario for curve_fit. A custom optimization routine
|
|
44
|
+
# might be more suitable for MultiProductDiffusionModel.
|
|
45
|
+
|
|
46
|
+
# For Phase 1, we will only support fitting of single-product models (Bass, Gompertz, Logistic)
|
|
47
|
+
# with curve_fit directly. MultiProductDiffusionModel will be fitted by manually
|
|
48
|
+
# setting parameters or with a more advanced fitter in later phases.
|
|
49
|
+
|
|
50
|
+
raise NotImplementedError("Fitting MultiProductDiffusionModel with ScipyFitter is not yet implemented. "
|
|
51
|
+
"Please use single-product models for now or manually set parameters for MultiProductDiffusionModel.")
|
|
52
|
+
|
|
53
|
+
# For single-product models, we can use the model's predict method as the curve_fit function
|
|
54
|
+
# The predict method expects (t, *params) if it's a static function,
|
|
55
|
+
# but here it's a method that uses internal state.
|
|
56
|
+
# So, we need a wrapper function that takes parameters as arguments.
|
|
57
|
+
|
|
58
|
+
# The model's _bass_cumulative, _gompertz_cumulative, _logistic_cumulative are suitable.
|
|
59
|
+
# We need to get the initial guesses and bounds from the model itself or provide them here.
|
|
60
|
+
|
|
61
|
+
# This requires a slight modification to how curve_fit is used with the DiffusionModel interface.
|
|
62
|
+
# The DiffusionModel.fit() method already handles curve_fit internally for single models.
|
|
63
|
+
# So, this ScipyFitter will primarily be used for more complex fitting scenarios or
|
|
64
|
+
# to provide a unified interface for different optimization backends.
|
|
65
|
+
|
|
66
|
+
# For Phase 1, the single-product models (Bass, Gompertz, Logistic) already have their
|
|
67
|
+
# own `fit` methods that use `curve_fit`. This `ScipyFitter` class will be more relevant
|
|
68
|
+
# when we introduce a generic `fit` method that can take any `DiffusionModel` and optimize it.
|
|
69
|
+
|
|
70
|
+
# Let's make this fitter work by calling the model's internal curve_fit logic
|
|
71
|
+
# or by providing a generic way to get the curve function and initial parameters.
|
|
72
|
+
|
|
73
|
+
# For now, this fitter will simply call the model's `fit` method.
|
|
74
|
+
# This makes the `ScipyFitter` a thin wrapper for Phase 1.
|
|
75
|
+
|
|
76
|
+
# In future phases, this `ScipyFitter` will contain the actual optimization logic
|
|
77
|
+
# for various models, including MultiProductDiffusionModel.
|
|
78
|
+
|
|
79
|
+
# Ensure the model has a fit method that accepts t and y
|
|
80
|
+
if not hasattr(model, 'fit') or not callable(model.fit):
|
|
81
|
+
raise AttributeError("Model must have a 'fit' method.")
|
|
82
|
+
|
|
83
|
+
# Call the model's own fit method
|
|
84
|
+
fitted_model = model.fit(t, y)
|
|
85
|
+
self.params_ = fitted_model.params_
|
|
86
|
+
return self
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Dict, Sequence, TypeVar
|
|
3
|
+
|
|
4
|
+
# Define a type variable for the class itself, for type hinting Self
|
|
5
|
+
Self = TypeVar('Self')
|
|
6
|
+
|
|
7
|
+
class DiffusionModel(ABC):
|
|
8
|
+
"""Abstract base class for all diffusion models."""
|
|
9
|
+
|
|
10
|
+
@abstractmethod
|
|
11
|
+
def fit(self, t: Sequence[float], y: Sequence[float]) -> Self:
|
|
12
|
+
"""Fits the diffusion model to the given time and adoption data."""
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def predict(self, t: Sequence[float]) -> Sequence[float]:
|
|
17
|
+
"""Predicts adoption levels for given time points."""
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def score(self, t: Sequence[float], y: Sequence[float]) -> float:
|
|
22
|
+
"""Returns the R^2 score of the model fit."""
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def params_(self) -> Dict[str, float]:
|
|
28
|
+
"""Returns a dictionary of fitted model parameters."""
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def predict_adoption_rate(self, t: Sequence[float]) -> Sequence[float]:
|
|
33
|
+
"""Predicts the rate of adoption (new adoptions per unit of time)."""
|
|
34
|
+
pass
|