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
2424Distributions are built on top of scipy, so "norm" refers to the name of the
2525normal distribution as given in `scipy.stats`, and the arguments to the
8484Sampling 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
8989Sampling 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
9595Here is an even more complex expression, showcasing some mathematical functions:
9696
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
104105Nodes are hashable and can be used in sets, so __hash__ and __eq__ must both
105106be defined. Therefore we cannot use `==` for modeling; equality in that context
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
208209Garbage 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