Skip to content

Commit bc28ab7

Browse files
authored
Allow correlations between more general sets of variables (#84)
* Allow correlations between more general sets of variables * fix doctest in README * Cleanup text and code * Wrote a test for trunc lognorm * version 0.4.0 * Update src/probabilit/modeling.py --------- Co-authored-by: tommyod <tommy.odland>
1 parent 78c4db0 commit bc28ab7

4 files changed

Lines changed: 199 additions & 83 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ How much money will we have after 20 years?
7474
... returns = returns * interest + saved_per_year
7575
>>> samples = returns.sample(999, random_state=42)
7676
>>> samples.mean(), samples.std()
77-
(np.float64(76583.58738496085), np.float64(33483.2245611436))
77+
(np.float64(76630.89701703968), np.float64(34507.63482771612))
7878

7979
```
8080

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "probabilit"
7-
version = "0.3.0"
7+
version = "0.4.0"
88
description = "A Python package for Monte Carlo sampling."
99
requires-python = ">= 3.10"
1010
readme = {file = "README.md", content-type = "text/markdown"}

src/probabilit/modeling.py

Lines changed: 113 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
>>> price = box_volume * price_per_sqm
2020
>>> samples = price.sample(999, random_state=rng)
2121
>>> float(np.mean(samples))
22-
20.00139737515...
22+
20.00876430957...
2323
2424
Distributions are built on top of scipy, so "norm" refers to the name of the
2525
normal distribution as given in `scipy.stats`, and the arguments to the
@@ -84,13 +84,13 @@
8484
Sampling any node is done by calling the `.sample()` method:
8585
8686
>>> expression.sample(5, random_state=rng)
87-
array([ 2.70764145, 36.58578812, 7.07064239, 1.84433247, 3.90951632])
87+
array([ 5.04060084, 5.19163254, 13.09590433, 20.23600678, 4.24147296])
8888
8989
Sampling the expression has the side effect that `.samples_` is populated on
9090
*every* ancestor node in the expression, for instance:
9191
9292
>>> a.samples_
93-
array([4.51589278, 4.37788659, 5.25960812, 5.80609507, 4.33770499])
93+
array([3.9254551 , 6.0788841 , 4.68923246, 3.48160505, 4.26149282])
9494
9595
Here is an even more complex expression, showcasing some mathematical functions:
9696
@@ -99,7 +99,8 @@
9999
>>> c = Distribution("norm", loc=0, scale=3)
100100
>>> expression = a*a - Add(a, b, c) + Abs(b)**Abs(c) + Exp(1 / Abs(c))
101101
>>> expression.sample(5, random_state=rng)
102-
array([ 4.70542018, 14.43250192, 6.74494838, -0.14020459, -3.27334554])
102+
array([ -4.9730559 , 103.74625257, 3.45199868, 14.50692883,
103+
31.07178136])
103104
104105
Nodes are hashable and can be used in sets, so __hash__ and __eq__ must both
105106
be defined. Therefore we cannot use `==` for modeling; equality in that context
@@ -202,7 +203,7 @@
202203
>>> hypercube = LatinHypercube(d=d, rng=rng, optimization="random-cd")
203204
>>> hypercube_samples = hypercube.random(5) # Draw 5 samples
204205
>>> expression.sample_from_quantiles(hypercube_samples)
205-
array([ 7.80785741, 3.72416016, 3.77849849, -3.83561905, 38.02479019])
206+
array([ 8.2746582 , 1.47340322, 1.71799415, 6.21188211, 41.63293414])
206207
207208
208209
Garbage collection
@@ -344,13 +345,6 @@ def python_to_prob(argument):
344345
#
345346
# Some further terminology:
346347
# * The _ancestors_ of node "-" are {"+", "2", "mu", "normal"}
347-
# * A node is said to be an _initial sampling node_ iff
348-
# (1) The node is a Distribution
349-
# (2) None of its ancestors are Distributions
350-
# For instance, in the graph above, the node "mu" is an initial sampling node.
351-
# Initial sampling nodes are the nodes that we can impose correlations on.
352-
# We cannot impose correlations on "normal" above, since its correlation
353-
# is determined by the graph structure.
354348
# * result.sample() samples the expression "mu + normal - 2" by propagating
355349
# through the graph, parents first. More specifically, each category of nodes
356350
# (Constant, Transform and Distribution) have their own internal _sample methods
@@ -582,10 +576,9 @@ def sample_from_quantiles(
582576
}
583577
if isinstance(correlator, str):
584578
correlator = correlator.lower().strip()
585-
if correlator not in CORRELATOR_MAP.keys():
586-
raise ValueError(
587-
f"`{correlator=}` must be in {set(CORRELATOR_MAP.keys())}"
588-
)
579+
valid_corrs = set(CORRELATOR_MAP.keys())
580+
if correlator not in valid_corrs:
581+
raise ValueError(f"`{correlator=}` not in {valid_corrs}")
589582
correlator = CORRELATOR_MAP[correlator] # Map to instance
590583

591584
# Prepare columns of quantiles, one column for each Distribution
@@ -599,25 +592,62 @@ def sample_from_quantiles(
599592
# Set up garbage collection
600593
gc = GarbageCollector(strategy=gc_strategy).set_sink(self)
601594

602-
# Start with initial sampling nodes, which contain independent variables
603-
initial_sampling_nodes = set(
604-
node for node in self.nodes() if node._is_initial_sampling_node()
605-
)
606-
# Ensure consistent ordering for reproducible results
607-
initial_sampling_nodes = sorted(initial_sampling_nodes, key=lambda n: n._id)
595+
# Keep track of all nodes that are sampled and later garbarge-collected.
596+
# If we do not keep track of these then they will be sampled twice.
597+
# We will skip sampling a node if either (1) samples_ is set or (2)
598+
# the node has previously been sampled and garbage collected.
599+
garbage_collected = set()
600+
601+
def topo_sample(G, gc, garbage_collected):
602+
"""Sample nodes in a graph G in topological order.
603+
604+
Both the arguments `gc` (garbage collector) and `garbage_collected`
605+
will be updated in place (mutated).
606+
"""
607+
608+
for node in nx.topological_sort(G):
609+
# Skip if samples already exists or the node was sampled previously
610+
if hasattr(node, "samples_") or node in garbage_collected:
611+
continue
612+
elif isinstance(node, Constant):
613+
node.samples_ = node._sample(size=size) # Draw constants
614+
elif isinstance(node, AbstractDistribution):
615+
node.samples_ = node._sample(q=next(columns)) # Sample distr
616+
elif isinstance(node, Transform):
617+
node.samples_ = node._sample() # Propagate through transform
618+
else:
619+
raise TypeError(
620+
"Node must be Constant, AbstractDistribution or Transform."
621+
)
622+
623+
is_numeric = (node.samples_ is not None) and np.issubdtype(
624+
node.samples_.dtype, np.number
625+
)
626+
if is_numeric and not np.all(np.isfinite(node.samples_)):
627+
msg = f"Sampling gave non-finite values: {node}\n{node.samples_}"
628+
raise ValueError(msg)
629+
630+
# Tell the garbage collector that we sampled this node.
631+
# If the reference counter reaches zero (a parent has no unsampled
632+
# children), then the `.samples_` attribute of the parent might
633+
# be deleted (if the garbage collection strategy allows it).
634+
garbage_collected.update(gc.decrement_and_delete(node))
635+
636+
# If there are no correlations to induce, then we can simply go through
637+
# the graph in topological order and sample it.
638+
# If there are correlations, then we must first sample up until and
639+
# including nodes that are to be correlated, then correlate them, then
640+
# sample the remaining graph. For instance, consider the graph:
641+
# A ----> B ---> [C] ---> D
642+
# |
643+
# v
644+
# E ---> [F] ---> G ----> result
645+
# If nodes C and F are to be correlated, then we sample A -> B -> C,
646+
# followed by E -> F, once we have samples on nodes C and F we
647+
# can correlate those permuting the order of the samples (ImanConover).
648+
# Finally we keep sampling D -> G -> result.
608649

609-
# Loop over all initial sampling nodes (ISN) and sample them
610650
G = self.to_graph()
611-
for node in initial_sampling_nodes:
612-
# Sample all ancestors of ISNs
613-
ancestors = G.subgraph(nx.ancestors(G, node))
614-
for ancestor in nx.topological_sort(ancestors):
615-
assert isinstance(ancestor, (Constant, Transform))
616-
ancestor.samples_ = ancestor._sample(size=size)
617-
618-
# Sample the ISN
619-
assert isinstance(node, AbstractDistribution)
620-
node.samples_ = node._sample(q=next(columns))
621651

622652
# Go through all ancestor nodes and create a list [(var, corr), ...]
623653
# that contains all correlations we must induce
@@ -626,11 +656,35 @@ def sample_from_quantiles(
626656
if hasattr(node, "_correlations"):
627657
correlations.extend(node._correlations)
628658

629-
# Check that each variable to correlate is an initial sampling node
630-
for variables, _ in correlations:
631-
for variable in variables:
632-
if variable not in initial_sampling_nodes:
633-
raise ValueError(f"Cannot correlate variable: {variable}")
659+
variable_sets = [set(variables) for (variables, _) in correlations]
660+
# Map all variables to integers to associate them with a column
661+
corr_variables = list(functools.reduce(set.union, variable_sets, set()))
662+
# Ensure consistent ordering for reproducible results
663+
corr_variables = sorted(corr_variables, key=lambda n: n._id)
664+
665+
# Check that the set of variables that the user wants to correlate
666+
# are allowed. The condition is that each variable and its ancestors
667+
# must be disjoint sets. For instance, in the graph A -> B we cannot
668+
# correlate A and B. In the graph A <- B -> C we cannot correlate
669+
# A and C. In general we can only correlate nodes whose correlation
670+
# cannot potentially be determined already from the graph structure.
671+
seen = set()
672+
for variable in corr_variables:
673+
var_plus_ancestors = set(variable.nodes())
674+
675+
# If the variable, or any ancestor, has already been seen
676+
if seen.intersection(var_plus_ancestors):
677+
msg = f"Cannot correlate node: {variable}\n"
678+
msg += "This variable is an ancestor of more than one variables\n"
679+
msg += "that you wish to correlate. But this relationship can\n"
680+
msg += "potentially already induce a correlation.\n"
681+
msg += (
682+
"For instance, in the graph A -> B you cannot correlate A and B.\n"
683+
)
684+
msg += "In the graph A <- B -> C you cannot correlate A and C."
685+
raise ValueError(msg)
686+
else:
687+
seen.update(var_plus_ancestors)
634688

635689
# Check that no correlation has been specified twice
636690
variable_sets = [set(variables) for (variables, _) in correlations]
@@ -639,11 +693,18 @@ def sample_from_quantiles(
639693
if len(common) > 1:
640694
raise ValueError(f"Correlations specified more than once: {common}")
641695

642-
# Map all variables to integers to associate them with a column
643-
all_variables = list(functools.reduce(set.union, variable_sets, set()))
644-
# Ensure consistent ordering for reproducible results
645-
all_variables = sorted(all_variables, key=lambda n: n._id)
646-
var_to_int = {v: i for (i, v) in enumerate(all_variables)}
696+
# Sample up until nodes that we induce correlations on. In this graph:
697+
# A ----> B ---> [C] ---> D
698+
# |
699+
# v
700+
# E ---> [F] ---> G ----> result
701+
# this means sampling up until and including C and F.
702+
for variable in corr_variables:
703+
ancestors = G.subgraph(nx.ancestors(G, variable).union({variable}))
704+
topo_sample(ancestors, gc=gc, garbage_collected=garbage_collected)
705+
706+
# Map to correlations
707+
var_to_int = {v: i for (i, v) in enumerate(corr_variables)}
647708
correlations = [
648709
(tuple(var_to_int[var] for var in variables), corrmat)
649710
for (variables, corrmat) in correlations
@@ -659,40 +720,18 @@ def sample_from_quantiles(
659720
correlator = correlator.set_target(correlation_matrix)
660721

661722
# Concatenate samples, correlate them (shift rows in each col), then re-assign
662-
samples_input = np.vstack([var.samples_ for var in all_variables]).T
723+
samples_input = np.vstack([var.samples_ for var in corr_variables]).T
663724
samples_ouput = correlator(samples_input)
664-
for var, sample in zip(all_variables, samples_ouput.T):
725+
for var, sample in zip(corr_variables, samples_ouput.T):
665726
var.samples_ = np.copy(sample)
666727

667-
# Iterate sampling though the graph
668-
for node in nx.topological_sort(G):
669-
if hasattr(node, "samples_"): # Skip if samples already exists
670-
pass
671-
elif isinstance(node, Constant):
672-
node.samples_ = node._sample(size=size) # Draw constants
673-
elif isinstance(node, AbstractDistribution):
674-
node.samples_ = node._sample(q=next(columns)) # Sample distr
675-
elif isinstance(node, Transform):
676-
node.samples_ = node._sample() # Propagate through transform
677-
else:
678-
raise TypeError(
679-
"Node must be Constant, AbstractDistribution or Transform."
680-
)
681-
682-
is_numeric = (node.samples_ is not None) and np.issubdtype(
683-
node.samples_.dtype, np.number
684-
)
685-
if is_numeric and not np.all(np.isfinite(node.samples_)):
686-
raise ValueError(
687-
f"Sampling this node gave non-finite values: {node}\n{node.samples_}"
688-
)
689-
690-
# Tell the garbage collector that we sampled this node.
691-
# If the reference counter reaches zero (a parent has no unsampled
692-
# children), then the `.samples_` attribute of the parent might
693-
# be deleted (only if the garbage collection strategy allows it).
694-
gc.decrement_and_delete(node)
695-
728+
# Sample all the way to the end. In this graph:
729+
# A ----> B ---> [C] ---> D
730+
# |
731+
# v
732+
# E ---> [F] ---> G ----> result
733+
# this would mean sampling from D and G.
734+
topo_sample(self.to_graph(), gc=gc, garbage_collected=garbage_collected)
696735
return self.samples_
697736

698737
def _is_initial_sampling_node(self):

0 commit comments

Comments
 (0)