Skip to content

Conversation

NathanielF
Copy link
Contributor

@NathanielF NathanielF commented Sep 27, 2025

Bayesian Workflow with SEMs

Related to proposal here
#806

Helpful links


📚 Documentation preview 📚: https://pymc-examples--807.org.readthedocs.build/en/807/

Signed-off-by: Nathaniel <[email protected]>
Copy link

Check out this pull request on  ReviewNB

See visual diffs & provide feedback on Jupyter Notebooks.


Powered by ReviewNB

Signed-off-by: Nathaniel <[email protected]>
@NathanielF NathanielF changed the title adding initial notebook Bayesian Workflow with SEMs Sep 27, 2025
@NathanielF
Copy link
Contributor Author

NathanielF commented Sep 28, 2025

Apparent indexing issue between pymc versions 5.17 --> 5.30

Indexing trick works for pymc 5.17, but breaks on 5.30

---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[15], [line 61](vscode-notebook-cell:?execution_count=15&line=61)
     [57](vscode-notebook-cell:?execution_count=15&line=57) priors = {"lambdas": [1, 0.5], "eta": 2, "B": [0, 0.5], "tau": [0, 1]}
     [59](vscode-notebook-cell:?execution_count=15&line=59) priors_wide = {"lambdas": [1, 5], "eta": 2, "B": [0, 5], "tau": [0, 10]}
---> [61](vscode-notebook-cell:?execution_count=15&line=61) sem_model_hierarchical_tight = make_hierarchical(priors, grp_idx)
     [62](vscode-notebook-cell:?execution_count=15&line=62) sem_model_hierarchical_wide = make_hierarchical(priors_wide, grp_idx)
     [64](vscode-notebook-cell:?execution_count=15&line=64) pm.model_to_graphviz(sem_model_hierarchical_tight)

Cell In[15], [line 52](vscode-notebook-cell:?execution_count=15&line=52)
     [50](vscode-notebook-cell:?execution_count=15&line=50)         Sigma_y.append(Sigma_y_g)
     [51](vscode-notebook-cell:?execution_count=15&line=51)     Sigma_y = pt.stack(Sigma_y)
---> [52](vscode-notebook-cell:?execution_count=15&line=52)     _ = pm.MvNormal("likelihood", mu=0, cov=Sigma_y[grp_idx], dims=('obs', 'indicators'))
     [54](vscode-notebook-cell:?execution_count=15&line=54) return sem_model_hierarchical

File ~/mambaforge/envs/pymc_examples_new/lib/python3.9/site-packages/pymc/distributions/distribution.py:310, in Distribution.__new__(cls, name, rng, dims, initval, observed, total_size, transform, *args, **kwargs)
    [307](https://file+.vscode-resource.vscode-cdn.net/Users/nathanielforde/Documents/Github/pymc-examples/examples/case_studies/~/mambaforge/envs/pymc_examples_new/lib/python3.9/site-packages/pymc/distributions/distribution.py:307)     elif observed is not None:
    [308](https://file+.vscode-resource.vscode-cdn.net/Users/nathanielforde/Documents/Github/pymc-examples/examples/case_studies/~/mambaforge/envs/pymc_examples_new/lib/python3.9/site-packages/pymc/distributions/distribution.py:308)         kwargs["shape"] = tuple(observed.shape)
--> [310](https://file+.vscode-resource.vscode-cdn.net/Users/nathanielforde/Documents/Github/pymc-examples/examples/case_studies/~/mambaforge/envs/pymc_examples_new/lib/python3.9/site-packages/pymc/distributions/distribution.py:310) rv_out = cls.dist(*args, **kwargs)
    [312](https://file+.vscode-resource.vscode-cdn.net/Users/nathanielforde/Documents/Github/pymc-examples/examples/case_studies/~/mambaforge/envs/pymc_examples_new/lib/python3.9/site-packages/pymc/distributions/distribution.py:312) rv_out = model.register_rv(
    [313](https://file+.vscode-resource.vscode-cdn.net/Users/nathanielforde/Documents/Github/pymc-examples/examples/case_studies/~/mambaforge/envs/pymc_examples_new/lib/python3.9/site-packages/pymc/distributions/distribution.py:313)     rv_out,
    [314](https://file+.vscode-resource.vscode-cdn.net/Users/nathanielforde/Documents/Github/pymc-examples/examples/case_studies/~/mambaforge/envs/pymc_examples_new/lib/python3.9/site-packages/pymc/distributions/distribution.py:314)     name,
   (...)
    [319](https://file+.vscode-resource.vscode-cdn.net/Users/nathanielforde/Documents/Github/pymc-examples/examples/case_studies/~/mambaforge/envs/pymc_examples_new/lib/python3.9/site-packages/pymc/distributions/distribution.py:319)     initval=initval,
    [320](https://file+.vscode-resource.vscode-cdn.net/Users/nathanielforde/Documents/Github/pymc-examples/examples/case_studies/~/mambaforge/envs/pymc_examples_new/lib/python3.9/site-packages/pymc/distributions/distribution.py:320) )
    [322](https://file+.vscode-resource.vscode-cdn.net/Users/nathanielforde/Documents/Github/pymc-examples/examples/case_studies/~/mambaforge/envs/pymc_examples_new/lib/python3.9/site-packages/pymc/distributions/distribution.py:322) # add in pretty-printing support

File ~/mambaforge/envs/pymc_examples_new/lib/python3.9/site-packages/pymc/distributions/multivariate.py:262, in MvNormal.dist(cls, mu, cov, tau, chol, lower, **kwargs)
    [259](https://file+.vscode-resource.vscode-cdn.net/Users/nathanielforde/Documents/Github/pymc-examples/examples/case_studies/~/mambaforge/envs/pymc_examples_new/lib/python3.9/site-packages/pymc/distributions/multivariate.py:259) @classmethod
    [260](https://file+.vscode-resource.vscode-cdn.net/Users/nathanielforde/Documents/Github/pymc-examples/examples/case_studies/~/mambaforge/envs/pymc_examples_new/lib/python3.9/site-packages/pymc/distributions/multivariate.py:260) def dist(cls, mu, cov=None, tau=None, chol=None, lower=True, **kwargs):
    [261](https://file+.vscode-resource.vscode-cdn.net/Users/nathanielforde/Documents/Github/pymc-examples/examples/case_studies/~/mambaforge/envs/pymc_examples_new/lib/python3.9/site-packages/pymc/distributions/multivariate.py:261)     mu = pt.as_tensor_variable(mu)
--> [262](https://file+.vscode-resource.vscode-cdn.net/Users/nathanielforde/Documents/Github/pymc-examples/examples/case_studies/~/mambaforge/envs/pymc_examples_new/lib/python3.9/site-packages/pymc/distributions/multivariate.py:262)     cov = quaddist_matrix(cov, chol, tau, lower)
    [263](https://file+.vscode-resource.vscode-cdn.net/Users/nathanielforde/Documents/Github/pymc-examples/examples/case_studies/~/mambaforge/envs/pymc_examples_new/lib/python3.9/site-packages/pymc/distributions/multivariate.py:263)     # PyTensor is stricter about the shape of mu, than PyMC used to be
    [264](https://file+.vscode-resource.vscode-cdn.net/Users/nathanielforde/Documents/Github/pymc-examples/examples/case_studies/~/mambaforge/envs/pymc_examples_new/lib/python3.9/site-packages/pymc/distributions/multivariate.py:264)     mu = pt.broadcast_arrays(mu, cov[..., -1])[0]

File ~/mambaforge/envs/pymc_examples_new/lib/python3.9/site-packages/pymc/distributions/multivariate.py:123, in quaddist_matrix(cov, chol, tau, lower, *args, **kwargs)
    [121](https://file+.vscode-resource.vscode-cdn.net/Users/nathanielforde/Documents/Github/pymc-examples/examples/case_studies/~/mambaforge/envs/pymc_examples_new/lib/python3.9/site-packages/pymc/distributions/multivariate.py:121)     cov = pt.as_tensor_variable(cov)
    [122](https://file+.vscode-resource.vscode-cdn.net/Users/nathanielforde/Documents/Github/pymc-examples/examples/case_studies/~/mambaforge/envs/pymc_examples_new/lib/python3.9/site-packages/pymc/distributions/multivariate.py:122)     if cov.ndim != 2:
--> [123](https://file+.vscode-resource.vscode-cdn.net/Users/nathanielforde/Documents/Github/pymc-examples/examples/case_studies/~/mambaforge/envs/pymc_examples_new/lib/python3.9/site-packages/pymc/distributions/multivariate.py:123)         raise ValueError("cov must be two dimensional.")
    [124](https://file+.vscode-resource.vscode-cdn.net/Users/nathanielforde/Documents/Github/pymc-examples/examples/case_studies/~/mambaforge/envs/pymc_examples_new/lib/python3.9/site-packages/pymc/distributions/multivariate.py:124) elif tau is not None:
    [125](https://file+.vscode-resource.vscode-cdn.net/Users/nathanielforde/Documents/Github/pymc-examples/examples/case_studies/~/mambaforge/envs/pymc_examples_new/lib/python3.9/site-packages/pymc/distributions/multivariate.py:125)     tau = pt.as_tensor_variable(tau)

ValueError: cov must be two dimensional.

This works in pymc 5.17

image
alpha = np.random.normal(0, 1, size=(2, 4))
M = np.random.normal(0, 1, size=(2, 12, 4))
inv_I_minus_B = np.random.normal(0,1, size=(2, 4, 4))
Lambda = np.random.normal(0,1, size=(12, 4))

Sigma_y = np.random.normal(0, 1, size=(2, 12, 12))
print("Sigma_y shape", Sigma_y.shape)

print(np.matmul(Lambda, inv_I_minus_B).shape)

mu_y = np.matmul(alpha[:, None, :], M.transpose(0, 2, 1))[:, 0, :]
print("Mu_y shape", mu_y.shape)

Signed-off-by: Nathaniel <[email protected]>
@fonnesbeck
Copy link
Member

I would ditch the Rhat plots -- all the action is in a tiny region just above 1.0, so most of the plot is irrelevant.

@NathanielF
Copy link
Contributor Author

@ricardoV94 , not looking for a review, just wondering about the shape handling in the MvNormal after pymc 5.17. If you see above i have a hierarchical SEM model which uses an indexing trick to pass group specific covariance structures to the likelihood. While this works in 5.17 see above it breaks in 5.30... is that a bug, or intended behaviour. Do you know how i could replicate the results with 5.30+?

Copy link

review-notebook-app bot commented Sep 30, 2025

View / edit / reply to this conversation on ReviewNB

ricardoV94 commented on 2025-09-30T10:28:00Z
----------------------------------------------------------------

Line #9.    corr_values = [

Nit this is a terrible way to have the values in the notebook for a reader. Just tell black to ignore and let multiple values per line


@ricardoV94
Copy link
Member

@NathanielF I didn't see any block failing in the notebook, can you give me a small snippet of code that is failing for you? You showed numpy code above

@NathanielF
Copy link
Contributor Author

The notebook code works but its running on pymc 5.17, you can see in the watermark... if i change or update the version. I tried to 5.30 it breaks on cell which creates the hierarchical modelling and gives the trace back you see above. I can run the notebook tonight on 5.30 and push it to show you

But it should break on the cell that defines the hierarchical model. Just under the section heading "hierarchical model on structural components"...

@ricardoV94
Copy link
Member

I see, let me take a quick look

@NathanielF
Copy link
Contributor Author

Thank you!

@ricardoV94
Copy link
Member

What do you mean by pymc 5.30, last release is 5.25

@ricardoV94
Copy link
Member

Unrelated but please don't do this pytensor.config.cxx = "/usr/bin/clang++". It's specific to your setup, probably macos, but it means that someone with a different config is going to loose all their C caching.

You can use a .pytensorrc in your home directory instead: https://pytensor.readthedocs.io/en/latest/library/config.html

@NathanielF
Copy link
Contributor Author

NathanielF commented Sep 30, 2025

What do you mean by pymc 5.30, last release is 5.25

Oh, shoot, sorry i think this is my fault. I was using 5.3.0. I thought because the pymc-examples pixi installer had the > 5.16... it was 5.30.

image

Sorry, my bad

@ricardoV94
Copy link
Member

ricardoV94 commented Sep 30, 2025

No worries, problem goes away then?

Suggestion use the newer syntax for set_subtensor:

Lambda = pt.set_subtensor(Lambda[0:3, 0], lambdas_1)
# Equivalent
Lambda = Lambda[0:3, 0].set(lambdas_1)

@NathanielF
Copy link
Contributor Author

Yes! I think problem goes away! Woop!

@ricardoV94
Copy link
Member

You can also use pytensor.linalg.block_diag for building the matrix if you want:

pt.linalg.block_diag(np.ones((3, 1)), np.ones((3, 1))*2, np.ones((3, 1))*3, np.ones((3, 1))).eval()
array([[1., 0., 0., 0.],
       [1., 0., 0., 0.],
       [1., 0., 0., 0.],
       [0., 2., 0., 0.],
       [0., 2., 0., 0.],
       [0., 2., 0., 0.],
       [0., 0., 3., 0.],
       [0., 0., 3., 0.],
       [0., 0., 3., 0.],
       [0., 0., 0., 1.],
       [0., 0., 0., 1.],
       [0., 0., 0., 1.]])

It will be more clever about the dot product with eta, since it can just dot the original matrices.

CC @jessegrabowski we should have a rewrite that canonicalizes nested set_subtensor of this kind to block_diag?

@NathanielF
Copy link
Contributor Author

You can also use pytensor.linalg.block_diag for building the matrix if you want:

pt.linalg.block_diag(np.ones((3, 1)), np.ones((3, 1))*2, np.ones((3, 1))*3, np.ones((3, 1))).eval()
array([[1., 0., 0., 0.],
       [1., 0., 0., 0.],
       [1., 0., 0., 0.],
       [0., 2., 0., 0.],
       [0., 2., 0., 0.],
       [0., 2., 0., 0.],
       [0., 0., 3., 0.],
       [0., 0., 3., 0.],
       [0., 0., 3., 0.],
       [0., 0., 0., 1.],
       [0., 0., 0., 1.],
       [0., 0., 0., 1.]])

It will be more clever about the dot product with eta, since it can just dot the original matrices.

CC @jessegrabowski we should have a rewrite that canonicalizes nested set_subtensor of this kind to block_diag?

That looks like it should be much neater

Copy link

review-notebook-app bot commented Sep 30, 2025

View / edit / reply to this conversation on ReviewNB

ricardoV94 commented on 2025-09-30T11:33:26Z
----------------------------------------------------------------

Line #9.        Lambda = pt.zeros((12, 4))

Any reason you didn't use make_Lambda in the first example?


NathanielF commented on 2025-09-30T11:44:00Z
----------------------------------------------------------------

None really, I think i was thinking pedagogically explaining how to abstract the pattern away into a function makes it cleaner we build more complex models.... but haven't finalised the write up around it.

Copy link
Contributor Author

None really, I think i was thinking pedagogically explaining how to abstract the pattern away into a function makes it cleaner we build more complex models.... but haven't finalised the write up around it.


View entire conversation on ReviewNB

@jessegrabowski
Copy link
Member

CC @jessegrabowski we should have a rewrite that canonicalizes nested set_subtensor of this kind to block_diag?

100%

@NathanielF NathanielF marked this pull request as ready for review October 1, 2025 13:51
@NathanielF
Copy link
Contributor Author

Marking this one as ready for review now. I won't do any more modelling work on it. I will revisit the write up and add a conclusion, but i think it might be good to get some feedback on the general structure and whether i hit the right notes about bayesian workflow/SEMs

cc @fonnesbeck

Also, @ricardoV94 i tried the pt.linalg method... and ran into a bug

with pm.Model(coords=coords) as cfa_model_v1:

    # --- Factor loadings ---
    lambdas_1 = make_lambda("indicators_1", "lambdas1", priors=[1, 0.5])
    lambdas_2 = make_lambda("indicators_2", "lambdas2", priors=[1, 0.5])
    lambdas_3 = make_lambda("indicators_3", "lambdas3", priors=[1, 0.5])
    lambdas_4 = make_lambda("indicators_4", "lambdas4", priors=[1, 0.5])

    #Lambda = make_Lambda(lambdas_1, lambdas_2, lambdas_3, lambdas_4)
    Lambda = pt.linalg.block_diag(lambdas_1[:, pt.newaxis], 
                                   lambdas_2[:, pt.newaxis], 
                                   lambdas_3[:, pt.newaxis], 
                                   lambdas_4[:, pt.newaxis])

    sd_dist = pm.Exponential.dist(1.0, shape=4)
    chol, _, _ = pm.LKJCholeskyCov("chol_cov", n=4, eta=2, sd_dist=sd_dist, compute_corr=True)
    eta = pm.MvNormal("eta", 0, chol=chol, dims=("obs", "latent"))

    # Construct Pseudo Observation matrix based on Factor Loadings
    mu = pt.dot(eta, Lambda.T)  # (n_obs, n_indicators)

    ## Error Terms
    Psi = pm.InverseGamma("Psi", 5, 10, dims="indicators")
    _ = pm.Normal(
        "likelihood", mu=mu, sigma=Psi, observed=observed_data, dims=("obs", "indicators")
    )
    
    ```
    
    But it gives the following error
    
    ```
    ---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[52], line 1
----> 1 idata_cfa_model_v1 = sample_model(cfa_model_v1, sampler_kwargs=sampler_kwargs)

Cell In[5], line 7, in sample_model(model, sampler_kwargs)
      5 with model:
      6     idata = pm.sample_prior_predictive()
----> 7     idata.extend(pm.sample(**sampler_kwargs, idata_kwargs={"log_likelihood": True}))
      8     idata.extend(pm.sample_posterior_predictive(idata))
      9 return idata

File ~/mambaforge/envs/bayesian_causal_book/lib/python3.10/site-packages/pymc/sampling/mcmc.py:718, in sample(draws, tune, chains, cores, random_seed, progressbar, progressbar_theme, step, var_names, nuts_sampler, initvals, init, jitter_max_retries, n_init, trace, discard_tuned_samples, compute_convergence_checks, keep_warning_stat, return_inferencedata, idata_kwargs, nuts_sampler_kwargs, callback, mp_ctx, blas_cores, model, **kwargs)
    715         auto_nuts_init = False
    717 initial_points = None
--> 718 step = assign_step_methods(model, step, methods=pm.STEP_METHODS, step_kwargs=kwargs)
    720 if nuts_sampler != "pymc":
    721     if not isinstance(step, NUTS):

File ~/mambaforge/envs/bayesian_causal_book/lib/python3.10/site-packages/pymc/sampling/mcmc.py:223, in assign_step_methods(model, step, methods, step_kwargs)
    221 if has_gradient:
    222     try:
--> 223         tg.grad(model_logp, var)  # type: ignore
    224     except (NotImplementedError, tg.NullTypeGradError):
    225         has_gradient = False

File ~/mambaforge/envs/bayesian_causal_book/lib/python3.10/site-packages/pytensor/gradient.py:633, in grad(cost, wrt, consider_constant, disconnected_inputs, add_names, known_grads, return_disconnected, null_gradients)
    630     if hasattr(g.type, "dtype"):
    631         assert g.type.dtype in pytensor.tensor.type.float_dtypes
--> 633 _rval: Sequence[Variable] = _populate_grad_dict(
    634     var_to_app_to_idx, grad_dict, _wrt, cost_name
    635 )
    637 rval: MutableSequence[Variable | None] = list(_rval)
    639 for i in range(len(_rval)):

File ~/mambaforge/envs/bayesian_causal_book/lib/python3.10/site-packages/pytensor/gradient.py:1425, in _populate_grad_dict(var_to_app_to_idx, grad_dict, wrt, cost_name)
   1422     # end if cache miss
   1423     return grad_dict[var]
-> 1425 rval = [access_grad_cache(elem) for elem in wrt]
   1427 return rval

File ~/mambaforge/envs/bayesian_causal_book/lib/python3.10/site-packages/pytensor/gradient.py:1425, in <listcomp>(.0)
   1422     # end if cache miss
   1423     return grad_dict[var]
-> 1425 rval = [access_grad_cache(elem) for elem in wrt]
   1427 return rval

File ~/mambaforge/envs/bayesian_causal_book/lib/python3.10/site-packages/pytensor/gradient.py:1380, in _populate_grad_dict.<locals>.access_grad_cache(var)
   1378 for node in node_to_idx:
   1379     for idx in node_to_idx[node]:
-> 1380         term = access_term_cache(node)[idx]
   1382         if not isinstance(term, Variable):
   1383             raise TypeError(
   1384                 f"{node.op}.grad returned {type(term)}, expected"
   1385                 " Variable instance."
   1386             )

File ~/mambaforge/envs/bayesian_causal_book/lib/python3.10/site-packages/pytensor/gradient.py:1057, in _populate_grad_dict.<locals>.access_term_cache(node)
   1054 if node not in term_dict:
   1055     inputs = node.inputs
-> 1057     output_grads = [access_grad_cache(var) for var in node.outputs]
   1059     # list of bools indicating if each output is connected to the cost
   1060     outputs_connected = [
   1061         not isinstance(g.type, DisconnectedType) for g in output_grads
   1062     ]

File ~/mambaforge/envs/bayesian_causal_book/lib/python3.10/site-packages/pytensor/gradient.py:1057, in <listcomp>(.0)
   1054 if node not in term_dict:
   1055     inputs = node.inputs
-> 1057     output_grads = [access_grad_cache(var) for var in node.outputs]
   1059     # list of bools indicating if each output is connected to the cost
   1060     outputs_connected = [
   1061         not isinstance(g.type, DisconnectedType) for g in output_grads
   1062     ]

File ~/mambaforge/envs/bayesian_causal_book/lib/python3.10/site-packages/pytensor/gradient.py:1380, in _populate_grad_dict.<locals>.access_grad_cache(var)
   1378 for node in node_to_idx:
   1379     for idx in node_to_idx[node]:
-> 1380         term = access_term_cache(node)[idx]
   1382         if not isinstance(term, Variable):
   1383             raise TypeError(
   1384                 f"{node.op}.grad returned {type(term)}, expected"
   1385                 " Variable instance."
   1386             )

File ~/mambaforge/envs/bayesian_causal_book/lib/python3.10/site-packages/pytensor/gradient.py:1210, in _populate_grad_dict.<locals>.access_term_cache(node)
   1202         if o_shape != g_shape:
   1203             raise ValueError(
   1204                 "Got a gradient of shape "
   1205                 + str(o_shape)
   1206                 + " on an output of shape "
   1207                 + str(g_shape)
   1208             )
-> 1210 input_grads = node.op.L_op(inputs, node.outputs, new_output_grads)
   1212 if input_grads is None:
   1213     raise TypeError(
   1214         f"{node.op}.grad returned NoneType, expected iterable."
   1215     )

File ~/mambaforge/envs/bayesian_causal_book/lib/python3.10/site-packages/pytensor/graph/op.py:398, in Op.L_op(self, inputs, outputs, output_grads)
    371 def L_op(
    372     self,
    373     inputs: Sequence[Variable],
    374     outputs: Sequence[Variable],
    375     output_grads: Sequence[Variable],
    376 ) -> list[Variable]:
    377     r"""Construct a graph for the L-operator.
    378 
    379     The L-operator computes a row vector times the Jacobian.
   (...)
    396 
    397     """
--> 398     return self.grad(inputs, output_grads)

File ~/mambaforge/envs/bayesian_causal_book/lib/python3.10/site-packages/pytensor/tensor/elemwise.py:299, in DimShuffle.grad(self, inp, grads)
    296     return [inp[0].zeros_like(dtype=config.floatX)]
    297 else:
    298     return [
--> 299         DimShuffle(tuple(s == 1 for s in gz.type.shape), grad_order)(
    300             Elemwise(scalar_identity)(gz)
    301         )
    302     ]

File ~/mambaforge/envs/bayesian_causal_book/lib/python3.10/site-packages/pytensor/tensor/elemwise.py:176, in DimShuffle.__init__(self, input_broadcastable, new_order)
    173             drop.append(i)
    174         else:
    175             # We cannot drop non-broadcastable dimensions
--> 176             raise ValueError(
    177                 "Cannot drop a non-broadcastable dimension: "
    178                 f"{input_broadcastable}, {new_order}"
    179             )
    181 # This is the list of the original dimensions that we keep
    182 self.shuffle = [x for x in new_order if x != "x"]

ValueError: Cannot drop a non-broadcastable dimension: (False, False), [0]

I think i can resolve it if instead of creating a new index dynamically i just sample a set of 3 x 1 matrices originally, but then it seems like that I need a redundant dummy dimension in the coords? This seems a little inelegant.

@NathanielF NathanielF requested a review from fonnesbeck October 1, 2025 15:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants