Package: copula-wrapper
Suppose we want to specify a continuousn-dimensional joint probability distribution by giving:narbitrary marginal distributionsa set of pairwise correlations between the dimensionsCopulasare a good way to achieve this separate specification of marginal distribution and dependence.This is a wrapper around theCopulaDistributionclass from thestatsmodelspackage, providing an interface that I prefer. (The rationale is disused in more detailbelow).Installationpipinstallcopula_wrapper# or `poetry add copula_wrapper`UsageDictionary arguments and Kendall's tau:fromcopula_wrapperimportCopulaJointfromscipyimportstatsimportseabornassnsmarginals={"consumption elasticity":stats.uniform(0.75,3),"market return":stats.lognorm(0.05,0.05),"risk of war":stats.beta(1,50)}tau={# Missing pairs are assumed to be independent("risk of war","market return"):-0.5,}# Instantiate the (frozen) joint distributiondist=CopulaJoint(marginals,kendall_tau=tau)# Query the CDF or PDFp=dist.cdf({"consumption elasticity":1.5,"market return":1.5,"risk of war":0.5})# Sample randomlysample=dist.rvs(100_000)# Plotsns.pairplot(sample,plot_kws={"s":1})Array arguments and Spearman's rho:importpandasaspdimportnumpyasnpfromcopula_wrapperimportCopulaJointfromscipyimportstatsimportseabornassnsmarginals=[stats.uniform(0.75,3),stats.lognorm(0.05,0.05),stats.beta(1,50)]spearman=np.eye(3)spearman[1,2]=spearman[2,1]=-0.5# Instantiate the (frozen) joint distributiondist=CopulaJoint(marginals,spearman_rho=spearman)# Query the CDF or PDFp=dist.cdf([1.5,1.5,0.5])# Sample randomlysample=dist.rvs(10_000)# Plotsns.pairplot(pd.DataFrame(sample),plot_kws={"s":1})Discussion of interfaceCopulaJointis a wrapper aroundCopulaDistributionfromstatsmodels.The context that motivated this wrapper was:A Monte Carlo simulation model with 10+ input dimensionsThe correlations come from subjective judgments / expert elicitationMany correlations are zeroUsers may not be familiar with or care about the mathematics of copulas (I wasn't) and may not be familiar with API conventions of SciPy (that also influencedstatsmodels).In my opinion, the terminology in the area invites confusion between a copula and the joint distribution (with arbitrary marginals) that it induces.How this wrapper changes the APIAbstracts away the underlying copula and takes in rank correlationsInstatsmodels, you'd do something like the followingfromstatsmodels.distributions.copula.apiimportCopulaDistribution,GaussianCopulafromscipyimportstatscorr=[[1.0,0.5],[0.5,1.0],]joint=CopulaDistribution(GaussianCopula(corr=corr),marginals=[stats.beta(2,2),stats.norm()])wherecorris the matrix of Pearson's rho correlations (the standard linear correlation coefficient most people are familiar with). IMO, this surfaces implementation details and requires the user to engage with the internals of their joint distribution, which they may not understand or care about. It also invites the misunderstanding that the joint distribution will have a Pearson's rho matching thecorrwe pass in. This is not the case, as the following example shows:importnumpyasnpfromscipyimportstatsfromstatsmodels.distributions.copula.apiimportCopulaDistribution,GaussianCopulac=0.8corr=[[1.0,c],[c,1.0],]joint=CopulaDistribution(GaussianCopula(corr=corr),marginals=[stats.norm(2,2),stats.lognorm(loc=0,s=1)])samples=joint.rvs(int(1e6))joint_r=stats.pearsonr(samples[:,0],samples[:,1])[0]# about 0.6assertnotnp.isclose(joint_r,c)Therefore, this wrapper instead takes rank correlations as input, and these are satisfied by the resulting joint distribution. It converts these to the appropriate correlation measure for the underlying copula. You must explicitly providekendall_tauorspearman_rho.(For now, only the Gaussian copula is supported. But I've written the class to be extensible easily and even put some hints as code comments. PRs welcome. If more copulas are supported, there will be a parameterfamilywith values like"Gaussian","Clayton"...)Allows dimension namesif dimensions are to be named,marginalsmust be a dict, andrank_corrmust be a dict
of tuples, e.g.fromscipyimportstatsmarginals={"a":stats.uniform(0,1),"b":stats.lognorm(2,3),"c":stats.norm(4,5),}rank_corr={# Missing pairs are assumed to be independent("a","b"):0.5,}and the first argument to.pdf,.cdf, and.logpdfmust be a dict with the same keys asmarginals.
(I originally had these methods support dimensions as keyword arguments, but I thought it was better not to
modify the API even more, plus dictionary keys can be any hashable object, not just strings.).If dimensions are named,.rvsreturns a Pandas DataFrame with dimension names as column names.Otherwise,marginalsmust be a list andrank_corrmust be a square matrix, and.rvsreturns a numpy array, just like in statsmodels.Satisfies interface of SciPy multivariate frozen distributionFor background, the Scipy pattern is that there is e.g. anorm_gensubclass ofrv_continuousthat is used to create thenorminstance, which can then optionally be 'frozen'.The statsmodelsCopulaDistributiondoes not follow this pattern. It acts 'frozen' in sofar
as the underlying copula (e.g. Gaussian) and its correlation matrix are specified at
initialization. However, it acts 'unfrozen' in thatcop_argsandmarg_argscan
be passed to.pdf,.cdf, and.logpdf. As a result,CopulaDistributiondoes not subclass
any of thescipy.statsclasses.I find this a bit inconsistent, and don't findcop_argsandmarg_argsuseful, so I
drop them in this wrapper.	The benefit is that the wrapper now satisfies the interface of
a SciPy multivariate frozen distribution.This means that the marginals provided toCopulaJointmust be frozen as well.It also means I usesize(SciPy) instead ofnobs(statsmodels) for the call torvs.TestsWhile the copula tests instatsmodelsare likely to be good for their intended purpose, I couldn't quickly decipher
exactly what behaviour these tests were checking for. In order to make sure this wrapper behaves in the way that its
interface promises, I created a set of end-to-end tests. These tests that check that random samples from the
joint distribution have:The expected rank correlation matrixThe expected marginal distributions (using the Kolmogorov-Smirnov statistic)The tests are parametrized (run for many inputs).As they involve large numbers of random samples, they are slow.There are also a few simple unit tests.