Skip to content

Commit c42bf75

Browse files
authored
Merge pull request #40 from RyanAugust/dev
Study result generation
2 parents a3bf5a2 + 5bf3728 commit c42bf75

File tree

7 files changed

+188
-8
lines changed

7 files changed

+188
-8
lines changed

README.md

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@ sim = simmm()
3131
mmm_input_df, channel_roi = sim.run_with_config(config=cfg)
3232
```
3333

34+
### Run via CLI
35+
36+
A configuration file is required as input for this and should be passed as seen below. An output path can also be passed via `-o`, however when not passed the current working directory will be used.
37+
38+
```bash
39+
pysimmm -i example_config.yaml -o .
40+
```
41+
3442
### Run by stages
3543

3644
Alternatively you may run each of the stages independently, which allows for easier debugging and in-run adjustments based on the results of each stage. The order of the stages is reflected below **(without their inputs)**. Once you've run through every stage, results are available by calling the `sim.final_df` object (channel ROI results are stored as `sim.channel_roi`).
@@ -48,14 +56,16 @@ sim.calculate_channel_roi()
4856
sim.finalize_output()
4957
```
5058

51-
### Run via CLI
59+
### Geographic distribution
5260

53-
A configuration file is required as input for this and should be passed as seen below. An output path can also be passed via `-o`, however when not passed the current working directory will be used.
61+
Marketing Mix Models may use geographic grain data for the purposes of budget allocation or during the calibration phase. PySiMMMulator provies `geos` to facilitate the generation of rancomized geographies as well as a distribution funciton to allocated synthetic data across the geographies.
5462

55-
```bash
56-
pysimmm -i example_config.yaml -o .
57-
```
5863

64+
### Study simulation
65+
66+
`study` and `batch_study` are also provided to simplify the simulated outcomes of marketing studies, which are an important component of MMM calibration.
67+
68+
Within this framework studies results are drawn from a normal distribution about the true value of a channels effectiveness (defaulted to ROI within this package). Both `study` and `batch_study` provide the ability to pass bias and standard deviation prameters for stationary and non-stationary distributions—allowing users to replicate a diverse set of real-world measurement difficulties.
5969
## Development
6070

6171
Setting up a dev environment

docs/source/index.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@ Welcome to PySiMMMulator docs!
1010

1111
Simulate <simulate.rst>
1212
Geos <geos.rst>
13+
Studies <study.rst>
1314

1415
Indices and tables
1516
==================
1617

1718
* :ref:`genindex`
1819
* :ref:`modindex`
19-
* :ref:`search`
20+
* :ref:`search`

docs/source/study.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
.. toctree::
2+
:maxdepth: 2
3+
4+
Studies
5+
=======
6+
7+
.. automodule:: pysimmmulator.study
8+
:members:
9+
:undoc-members:
10+

src/pysimmmulator/VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.4.4
1+
0.5.0

src/pysimmmulator/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22
__author__ = "RyanAugust"
33
__license__ = "MIT"
44
__copyright__ = "Copyright 2025"
5-
__version__ = "0.4.4"
5+
__version__ = "0.5.0"
66

77
import os
88

99
from .simulate import simmm, multisimmm
1010
from .load_parameters import load_config, define_basic_params
1111
from .geos import geos, distribute_to_geos
12+
from .study import study, batch_study

src/pysimmmulator/study.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"""Generation of calibration study results"""
2+
from typing import List, Optional, Dict
3+
import numpy as np
4+
5+
DEFAULT_STUDY_BIAS = 0.0
6+
DEFAULT_STUDY_SCALE = 0.05
7+
8+
class study:
9+
"""Object for generating study values from a normal distribution around true the true channel roi"""
10+
def __init__(self, channel_name:str, true_roi:float, random_seed:int=None, bias:float=DEFAULT_STUDY_BIAS, stdev:float=DEFAULT_STUDY_SCALE) -> None:
11+
self.channel_name = channel_name
12+
self._true_roi = true_roi
13+
self.rng = self._create_random_factory(seed=random_seed)
14+
self._bias = bias
15+
self._stdev = stdev
16+
17+
@property
18+
def roi(self) -> float:
19+
"""Reports the true ROI of the channel set at initializaiton
20+
21+
Returns:
22+
true_roi (float): the true ROI value for the channel."""
23+
return self._true_roi
24+
25+
def _create_random_factory(self, seed: int) -> np.random.Generator:
26+
"""Internal helper that serves as a central random number generator,
27+
and can be initialized with a seed to enable testing.
28+
29+
Args:
30+
seed (int): Optional seed value for random number generation
31+
Returns:
32+
rng (np.random.Generator): random number generator"""
33+
rng = np.random.default_rng(seed=seed)
34+
return rng
35+
36+
def update_bias(self, value:float) -> None:
37+
"""Updates the distribution bias to the passed value
38+
39+
Args:
40+
value (float): value to set the distribution bias to
41+
Returns:
42+
None"""
43+
self._bias = value
44+
45+
def update_stdev(self, value:float) -> None:
46+
"""Updates the distribution stdev to the passed value
47+
48+
Args:
49+
value (float): value to set the distribution stdev to
50+
Returns:
51+
None"""
52+
self._stdev = value
53+
54+
def update_roi(self, value:float) -> None:
55+
"""Updates the roi assigned to the channel as the passed value
56+
57+
Args:
58+
value (float): value to set the channel roi to
59+
Returns:
60+
None"""
61+
self._true_roi = value
62+
63+
def generate(self, count:int=1) -> 'np.array':
64+
"""Provides a study 'result'
65+
66+
Args:
67+
count (int): number of study results to return (default is 1)
68+
Retuns:
69+
study_results (iterable[float]): an array of study results """
70+
return self.rng.normal(loc=self._true_roi + self._bias, scale=self._stdev, size=count)
71+
72+
def generate_dynamic(self, bias:list[float], stdev:list[float]) -> list:
73+
"""Provides study results with non-stationary distribution
74+
75+
Args:
76+
bias (list[float]): iterable of bias values used to update the distribution per results
77+
stdev (list[float]): iterable of stdev values used to update the distribution per results
78+
Returns:
79+
study_results (iterable[float]): an array of study results """
80+
results = []
81+
for b, z in zip(bias, stdev):
82+
self.update_bias(b)
83+
self.update_stdev(z)
84+
results.append(self.generate()[0])
85+
return results
86+
87+
class batch_study:
88+
"""Object for generating study values across all channels"""
89+
def __init__(self, channel_rois:dict, channel_distributions:dict[str, dict]=dict(), random_seed:int=None, bias:float=DEFAULT_STUDY_BIAS, stdev:float=DEFAULT_STUDY_SCALE) -> None:
90+
self._study_hold = {k: study(channel_name=k, true_roi=v, random_seed=random_seed, bias=channel_distributions.get(k, {}).get("bias",bias), stdev=channel_distributions.get(k, {}).get("stdev",stdev)) for k, v in channel_rois.items()}
91+
92+
def generate(self, count:int=1) -> dict[str, 'np.array']:
93+
"""Produces study results for all of the registered channels
94+
95+
Args:
96+
count (int): number of study results to return (default is 1)
97+
Retuns:
98+
study_results (dict[iterable[float]]): an array of study results"""
99+
return {k: v.generate(count) for k, v in self._study_hold.items()}
100+
101+
def generate_dynamic(self, universal_bias: Optional[List[float]] = None, universal_stdev: Optional[List[float]] = None,
102+
channel_bias: Optional[dict[str, list[float]]]=None, channel_stdev: Optional[dict[str, list[float]]]=None) -> dict[str, list[float]]:
103+
"""Produces study results for all of the registered channels
104+
105+
Args:
106+
universal_bias (List[float]): iterable of bias values used to update the distribution per results
107+
universal_stdev (List[float]): iterable of stdev values used to update the distribution per results
108+
channel_bias (dict[str, list[float]]): lookup of iterable of bias values used to update the distribution per results
109+
channel_stdev (dict[str, list[float]]): iterable of stdev values used to update the distribution per results
110+
Returns:
111+
study_results (iterable[float]): an array of study results """
112+
assert all(x is not None for x in [universal_bias, universal_stdev]) or all(x is not None for x in [channel_bias, channel_stdev]), "both Universal or both channel specs must be passed"
113+
results = {channel: [] for channel in self._study_hold.keys()}
114+
if all(x is not None for x in [universal_bias, universal_stdev]):
115+
for b, z in zip(universal_bias, universal_stdev):
116+
for channel, study in self._study_hold.items():
117+
study.update_bias(b)
118+
study.update_stdev(z)
119+
results[channel].append(study.generate()[0])
120+
return results
121+
if all(x is not None for x in [channel_bias, channel_stdev]):
122+
for channel, study in self._study_hold.items():
123+
for b, z in zip(channel_bias[channel], channel_stdev[channel]):
124+
study.update_bias(b)
125+
study.update_stdev(z)
126+
results[channel].append(study.generate()[0])
127+
return results
128+

tests/test_study.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from pysimmmulator import study, batch_study
2+
3+
def test_single_channel_study():
4+
google = study("search", 1.600)
5+
assert 0.0 < google.generate()[0] < 3.20
6+
7+
def test_dynamic_single_channel_study():
8+
google = study("search", 1.600)
9+
results = google.generate_dynamic([i/10.0 for i in range(5)], [i/10.0 for i in range(5)])
10+
assert len(results) == 5
11+
12+
def test_multiple_channel_study():
13+
channel_spec = {"google_search": 1.600, "youtube": 9.01}
14+
batch = batch_study(channel_spec)
15+
assert 0.0 < batch.generate()["youtube"][0] < 20.06
16+
17+
def test_dynamic_multiple_channel_universal_study():
18+
channel_spec = {"google_search": 1.600, "youtube": 9.01}
19+
batch = batch_study(channel_spec)
20+
results = batch.generate_dynamic(universal_bias=[i/10.0 for i in range(5)], universal_stdev=[i/10.0 for i in range(5)])
21+
assert 0.0 < results["youtube"][0] < 20.06
22+
23+
def test_dynamic_multiple_channel_study():
24+
channel_spec = {"google_search": 1.600, "youtube": 9.01}
25+
batch = batch_study(channel_spec)
26+
results = batch.generate_dynamic(channel_bias={"google_search":[i/10.0 for i in range(5)],
27+
"youtube":[i/10.0 for i in range(5)]},
28+
channel_stdev={"google_search":[i/10.0 for i in range(5)],
29+
"youtube":[i/10.0 for i in range(5)]})
30+
assert 0.0 < results["youtube"][0] < 20.06

0 commit comments

Comments
 (0)