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.
Files changed (35) hide show
  1. innovate-0.1.0/PKG-INFO +60 -0
  2. innovate-0.1.0/README.md +43 -0
  3. innovate-0.1.0/pyproject.toml +30 -0
  4. innovate-0.1.0/setup.cfg +4 -0
  5. innovate-0.1.0/src/innovate/__init__.py +0 -0
  6. innovate-0.1.0/src/innovate/backend.py +34 -0
  7. innovate-0.1.0/src/innovate/backends/jax_backend.py +44 -0
  8. innovate-0.1.0/src/innovate/backends/numpy_backend.py +49 -0
  9. innovate-0.1.0/src/innovate/fitters/__init__.py +0 -0
  10. innovate-0.1.0/src/innovate/fitters/batched_fitter.py +55 -0
  11. innovate-0.1.0/src/innovate/fitters/bootstrap_fitter.py +67 -0
  12. innovate-0.1.0/src/innovate/fitters/jax_fitter.py +37 -0
  13. innovate-0.1.0/src/innovate/fitters/mom_fitter.py +93 -0
  14. innovate-0.1.0/src/innovate/fitters/scipy_fitter.py +86 -0
  15. innovate-0.1.0/src/innovate/models/base.py +34 -0
  16. innovate-0.1.0/src/innovate/models/bass.py +85 -0
  17. innovate-0.1.0/src/innovate/models/competition.py +197 -0
  18. innovate-0.1.0/src/innovate/models/gompertz.py +73 -0
  19. innovate-0.1.0/src/innovate/models/logistic.py +97 -0
  20. innovate-0.1.0/src/innovate/plots.py +63 -0
  21. innovate-0.1.0/src/innovate/utils/__init__.py +0 -0
  22. innovate-0.1.0/src/innovate/utils/categorization.py +43 -0
  23. innovate-0.1.0/src/innovate/utils/metrics.py +50 -0
  24. innovate-0.1.0/src/innovate/utils/model_evaluation.py +81 -0
  25. innovate-0.1.0/src/innovate/utils/preprocessing.py +82 -0
  26. innovate-0.1.0/src/innovate.egg-info/PKG-INFO +60 -0
  27. innovate-0.1.0/src/innovate.egg-info/SOURCES.txt +33 -0
  28. innovate-0.1.0/src/innovate.egg-info/dependency_links.txt +1 -0
  29. innovate-0.1.0/src/innovate.egg-info/requires.txt +9 -0
  30. innovate-0.1.0/src/innovate.egg-info/top_level.txt +1 -0
  31. innovate-0.1.0/tests/test_batched_fitter.py +74 -0
  32. innovate-0.1.0/tests/test_categorization.py +25 -0
  33. innovate-0.1.0/tests/test_fitters.py +69 -0
  34. innovate-0.1.0/tests/test_models.py +177 -0
  35. innovate-0.1.0/tests/test_preprocessing.py +30 -0
@@ -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.
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
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