Repository: scipy/scipy

Issue:
ENH: Generation of random 2D tables with given marginal totals
### Is your feature request related to a problem? Please describe.

## Motivation

Sometimes you have pairs of values x, y and you want to know whether y is independent of x or not. People sometimes compute a test statistic of x, y pairs directly, examples are Pearson/Spearman coefficient, Kendall tau or similar.

Other tests use contingency tables (histograms), like the popular [USP test](https://royalsocietypublishing.org/doi/10.1098/rspa.2021.0549). This can be more computationally efficient when the sample is very large (think of applications with Big Data, like in my field, high-energy physics). For these tests, one needs to generate random 2D tables that have the same row and column totals as the original data. The probability distribution for such a table has been worked out in the 80'ies, and Patefield found an elegant non-trivial algorithm to randomly sample from this distribution.

### Describe the solution you'd like.

I searched the net for implementations in C/C++ of Patefield's algorithm, but only found implementations under GPL license and none of them is available to Python. I want to have the code under a less restrictive license and so I translated the published FORTRAN code to C from scratch and [based my implementation on that](https://github.com/resample-project/resample/blob/develop/src/rcont.c). My code is licensed under BSD 3-clause and I would like to contribute it to SciPy.

My implementation already evolved from the original and has cool features that the original implementation lacks. The changes are documented in the code. Sorry for all the `goto`s, it is how the original code was written. In time, I am interested in removing the `goto`s step by step, but for now it is more important to have a baseline code that works.

In addition to Patefield's algorithm, I also want to add a simple shuffling algorithm. The shuffling algorithm requires additional memory, but is faster than Patefield in certain scenarios. Patefield is more time efficient when the table contains few cells and depends only logarithmically on the number of total entries, while the time for the shuffling algorithm does not depend on the number of cells and only depends on the number of entries.

I would like to start a PR; some pointers where this algorithm fits in best, would be helpful.

### Describe alternatives you've considered.

As mentioned above, before I started writing mine, there were only GPL licensed implementations and none available to Python.

### Additional context (e.g. screenshots, GIFs)

_No response_


Discussion:
Hi, thanks for the proposal @HDembinski. I am not sure to understand, could you give some concrete numerical examples? Is this similar to [`owens_t`](https://scipy.github.io/devdocs/reference/generated/scipy.special.owens_t.html)?
owens_t is something different as you can see from the docs of that function. If you look at the links that listed, it should become more clear. I hope. The paper for the USP test was written for non experts, is open access and very accessible.
Where would be the best place to put `rcont1` and `rcont2` in scipy?
> Where would be the best place to put `rcont1` and `rcont2` in scipy?

I am not sure yet if it should be included or not. @mdhaber what do you think?
Sounds like a multivariate distribution? Many in of them have only `rvs` implemented.
Would you send an email to the [scipy-dev list](https://mail.python.org/mailman3/lists/scipy-dev.python.org/) about this and invite people to this issue for discussion? Then we can make a decision based on wider feedback.
@HDembinski I do think this could be in scope if it were added as a multivariate distribution. Does that sound reasonable? What would its name be, and what parameters would it have? (It wouldn't necessarily need any methods except for `rvs`, but in principle, other methods could be added.)
The algorithm only makes sense for 2D matrices, since you have pairs of values that are independent and not tuples.
But each of the two dimensions can be of any size (in principle), right? Several of the multivariate distributions produce 2D random variates (e.g. [random_correlation](https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.random_correlation.html#scipy.stats.random_correlation)).

To check my understanding: suppose the tables generated by your routine are 2 x 2 with specified marginals. Aren't these tables distributed the same as in the null hypothesis of a Fisher's exact test? 
@HDembinski are you still interested in contributing this? I think it could be quite useful (e.g. for gh-7099) and I'd be willing to review it if it were implemented in Python, but I'm afraid I can't help with the C. 

I still think it would fit as a multivariate distribution. The random variable here is a 2D matrix. If we can figure out a name for this distribution, I think it would have an `rvs` method that accepts parameters `r` (for row sums), `c` (column sums), `size` (the number of variates to draw), and `random_state` (the RNG object or seed).

Suppose the name were `patefield`. It might be called like:
```python3
rng = np.random.default_rng(3459034)
size = 2
r = [5, 2]
c = [3, 4]
patefield.rvs(r, c, size=size, random_state=rng)
```
which might produce:
```
array([[[3, 1],
        [2, 1]],

       [[4, 0],
        [1, 2]]])
```

> @HDembinski are you still interested in contributing this? I think it could be quite useful (e.g. for [gh-7099](https://github.com/scipy/scipy/issues/7099)) and I'd be willing to review it if it were implemented in Python, but I'm afraid I can't help with the C.

Cool, thanks for that. Yes, I am still interested. I also [convinced the Boost.Math maintainer](https://github.com/boostorg/math/issues/761) that it would be good to add these algorithms to Boost.Math, therefore my plan was to add it first to Boost.Math and then this can be wrapped to scipy?

Unless we are in a hurry, then I can try to add it here and in parallel to Boost.Math. I cannot predict how long it will take to get the PR merged into Boost.Math.

**Edit:** I think it makes sense to add it first here, because that will be much simpler. Boost.Math currently has no random distribution that returns an array or a matrix, so we have to figure out an appropriate API for this first. When the implementation in Boost.Math is finished, we can switch the implementation in Scipy over to Boost.Math so that there is no duplication.

> I still think it would fit as a multivariate distribution. The random variable here is a 2D matrix. If we can figure out a name for this distribution, I think it would have an `rvs` method that accepts parameters `r` (for row sums), `c` (column sums), `size` (the number of variates to draw), and `random_state` (the RNG object or seed).

Yeah, that makes sense.

> Suppose the name were `patefield`. It might be called like:
> 
> ```python
> rng = np.random.default_rng(3459034)
> size = 2
> r = [5, 2]
> c = [3, 4]
> patefield.rvs(r, c, size=size, random_state=rng)
> ```
> 
> which might produce:
> 
> ```
> array([[[3, 1],
>         [2, 1]],
> 
>        [[4, 0],
>         [1, 2]]])
> ```

The interface looks good, but I think we should use a more generic name. There are two algorithms that can generate these tables, which one is faster depends on the total number of entries in the table and the number of cells. Patefield is one of them, the other one is the trivial shuffling algorithm that I mentioned above (since it is trivial, I don't claim any intellectual ownership).

In the R language, the function is called [r2dtable](https://rdrr.io/r/stats/r2dtable.html). Not a great name, but I have no better idea at the moment.

Brainstorming:
- conditional_table
- conditional_matrix
- table_with_marginals

> I think it makes sense to add it first here...
>There are two algorithms that can generate these tables,

OK. If it's going here first, let's start with the trivial algorithm implemented in pure Python. This would allow us to work out the API and get the new class approved on the mailing list, saving the more complicated algorithmic stuff for a follow-up PR.

Does that sound good?

> I think we should use a more generic name.

Sure, I agree. Let's use "table" instead of "matrix" just to avoid any possibility of confusion with `np.matrix`. I think `conditional_table` is apt and concise, but `table_with_marginals` (or `fixed_marginal_table`?) is a little more obvious... I could go either way. Pick your favorite, and we'll propose it on the mailing list once there's a PR for people to look at. Perfect naming is hard; all we can do is pick one that nobody strongly disagrees with.


@mdhaber I looked into scipy.stats._multivariate and I don't know how to implement all of the methods for a general distribution object. There is not only `rvs`, but also `mean`, `pmf` (and `logpmf`), `var`, `cov`. I know what `mean` should return, but I have no idea about `pmf`, `var`, and `cov`.
There are no required methods for the multivariate distributions because they are all much more diverse than the univariate ones (and are often in spaces that aren't Euclidean vector spaces, like here). Implement what makes sense for your distribution.

As for the `pmf`, if I'm reading the references correctly, this distribution is uniform over all of the contingency tables that have the given marginal sums, right? Then the `pmf` would be not particularly useful to anyone; it's either 0 when the marginals aren't met and the reciprocal of the count of valid contingency tables otherwise. Computing that count might be hard, and there isn't really anything useful one could want to do with that PMF (nothing to optimize), so I wouldn't worry about implementing it.
For 2x2 tables, the PMF is given by the hypergeometric distribution.  For tables with shape (m, n), the appropriate generalization is needed.  A contingency table is generated by counting unique pairs of some data set.  Each possible data set with the prescribed quantities of categories in the components of the pairs (i.e. with given marginal sums) is equally likely, but multiple data sets can result in the same contingency table.
As far as I can tell, the algorithm here samples uniformly from the set of _contingency tables_ with the given marginal sums, not uniformly from the set of datasets with the given marginal sums.
@rkern @WarrenWeckesser Thanks for the quick feedback! Ok, I will carry on then and implement what I know.

> this distribution is uniform over all of the contingency tables that have the given marginal sums, right?

I am not sure that one can express it that way. I think Warren is right that 2x2 tables are described by the hypergeometric distribution. Another way to describe the set is as a subset of the multinominal distribution. The multinominal distribution requires that the sum of all entries is fixed, while this distribution requires also row and column sums to be fixed. The row and column sums also fully determine the expected frequency for each cell in the table.

Looking at the hypergeometric distribution, there is probably a closed formula for the covariance of this distribution, but I don't know what it is.
Okay, yeah, I was looking at some later papers that referenced Patefield that were in fact sampling uniformly from the set of contingency tables. But Patefield is not doing that. In any case, he references an [earlier paper](https://www.jstor.org/stable/2347219) that does give the PMF explicitly.
FYI, and only noted as an alternative to which a more efficient implementation can be compared: In the github repo [numerand](https://github.com/WarrenWeckesser/numerand), I have an implementation of a brute-force method for generating random contingency tables.  To generate a random table, it generates an appropriately sized dataset with values that match the given marginals, shuffles the appropriate components to randomize the dataset, and then counts the result.  It works fine for marginal sums that are not very big, but the algorithm is O(N) in memory and time, where N is the sum of the values in any contingency table, so it is not recommended for tables with large marginal sums.  As I wrote in the docstring of `random_table`, "There is a lot of literature on the efficient generation of random contingency tables that has been completely ignored here!"  It does handle 2x2 tables as a special case, where it uses the hypergeometric distribution to generate the random tables.

Some scratch-work for a C implementation of the same brute-force method is in https://github.com/WarrenWeckesser/experiments/tree/master/c/randtable
I asked @HDembinski to start with such a brute-force algorithm in Python so we can get the interface approved (i.e. not rejected) by the mailing list and the input validation/testing figured out. In a follow-up PR, I suggested that we could speed up the algorithm using Pythran, or we could replace it with the Patefield algorithm (or choose based on properties of the table), then speed it up with Pythran. We could go with C/C++ instead of Pythran, but in that case I can't help as much.

Is this a brute-force algorithm for this?
```python3
import numpy as np
from scipy import stats
rng = np.random.default_rng(1638083107694713882823079058616272161)

# inputs
row_sums = [10, 20, 30]
col_sums = [25, 35]
n_tables = 10

# implementation
x = np.concatenate([[i]*j for i, j in enumerate(row_sums)])
y = np.concatenate([[i]*j for i, j in enumerate(col_sums)])
def crosstab(x, y):
    return stats.contingency.crosstab(x, y).count
tables = [crosstab(x, rng.permutation(y)) for i in range(n_tables)]

# testing
res, ref = np.broadcast_arrays(np.sum(tables, axis=-1), row_sums)
np.testing.assert_array_equal(res, ref)
res, ref = np.broadcast_arrays(np.sum(tables, axis=-2), col_sums)
np.testing.assert_array_equal(res, ref)

```
Yes, that's what I have been calling the "brute force" method.  You can also compare `np.mean(tables, axis=0)` to `stats.contingency.expected_freq(tables[0])`.  With `n_tables` increased to `10000`, I get:
```
In [63]: np.mean(tables, axis=0)
Out[63]: 
array([[ 4.1704,  5.8296],
       [ 8.307 , 11.693 ],
       [12.5226, 17.4774]])

In [64]: stats.contingency.expected_freq(tables[0])
Out[64]: 
array([[ 4.16666667,  5.83333333],
       [ 8.33333333, 11.66666667],
       [12.5       , 17.5       ]])
```
The code in `numerand.random_table` uses a somewhat faster cross-tab function that is specialized to this particular case.  It also (by default) uses the hypergeometric distribution for the 2x2 case, which is *much* faster than the brute force method.
> I have an implementation of a brute-force method for generating random contingency tables.

@WarrenWeckesser Yes, that's the "shuffling algorithm" I refer to in my OP. I have a C implementation of that algorithm [which I use in https://github.com/scikit-hep/resample](https://github.com/scikit-hep/resample/blob/develop/src/rcont.c#L47). I am currently mapping that to Python and will compare with @mdhaber implementation.

> In any case, he references an [earlier paper](https://www.jstor.org/stable/2347219) that does give the PMF explicitly.

Ah, cool.


> In a follow-up PR, I suggested that we could speed up the algorithm using Pythran, or we could replace it with the Patefield algorithm (or choose based on properties of the table), then speed it up with Pythran. We could go with C/C++ instead of Pythran, but in that case I can't help as much.

I did some benchmarks already and both the shuffling and Patefield's algorithm are useful to have. They have different time complexity, not to mention the space complexity of the shuffling algorithm which makes this algorithm unfeasible for tables with total number of entries N in the millions. Basically, the shuffling algorithm has O(N) complexity, but is "constant"  with respect to the number of cells K (eventually you see cache misses for large K), while Patefield scales with K x log(N). Patefield wins when N is large, but typically loses if N is small (in the hundreds or less).

I think we should support both with a keyword "method" or similar. If method is None, we can select the more appropriate algorithm empirically based on the inputs.

Note that I have both algorithms already written in C, so we can also just use these versions instead of accelerating the Python version with Pythran.

Resolving patch:
diff --git a/scipy/stats/__init__.py b/scipy/stats/__init__.py
--- a/scipy/stats/__init__.py
+++ b/scipy/stats/__init__.py
@@ -173,6 +173,7 @@
    random_correlation     -- random correlation matrices
    multivariate_t         -- Multivariate t-distribution
    multivariate_hypergeom -- Multivariate hypergeometric distribution
+   random_table           -- Distribution of random tables with given marginals
 
 `scipy.stats.multivariate_normal` methods accept instances
 of the following class to represent the covariance.
diff --git a/scipy/stats/_multivariate.py b/scipy/stats/_multivariate.py
--- a/scipy/stats/_multivariate.py
+++ b/scipy/stats/_multivariate.py
@@ -14,8 +14,7 @@
 from scipy.linalg.lapack import get_lapack_funcs
 
 from ._discrete_distns import binom
-from . import _mvn, _covariance
-
+from . import _mvn, _covariance, contingency
 
 __all__ = ['multivariate_normal',
            'matrix_normal',
@@ -28,7 +27,8 @@
            'random_correlation',
            'unitary_group',
            'multivariate_t',
-           'multivariate_hypergeom']
+           'multivariate_hypergeom',
+           'random_table']
 
 _LOG_2PI = np.log(2 * np.pi)
 _LOG_2 = np.log(2)
@@ -5075,3 +5075,452 @@ def rvs(self, size=1, random_state=None):
         method.__doc__, mhg_docdict_noparams)
     method.__doc__ = doccer.docformat(method.__doc__,
                                       mhg_docdict_params)
+
+
+class random_table_gen(multi_rv_generic):
+    r"""Contingency tables from independent samples with fixed marginal sums.
+
+    This is the distribution of random tables with given row and column vector
+    sums. This distribution represents the set of random tables under the null
+    hypothesis that rows and columns are independent. It is used in hypothesis
+    tests of independence.
+
+    Because of assumed independence, the expected frequency of each table
+    element can be computed from the row and column sums, so that the
+    distribution is completely determined by these two vectors.
+
+    Methods
+    -------
+    logpmf(x)
+        Log-probability of table `x` to occur in the distribution.
+    pmf(x)
+        Probability of table `x` to occur in the distribution.
+    mean(row, col)
+        Mean table.
+    rvs(row, col, size=None, method=None, random_state=None)
+        Draw random tables with given row and column vector sums.
+
+    Parameters
+    ----------
+    %(_doc_row_col)s
+    %(_doc_random_state)s
+
+    Notes
+    -----
+    %(_doc_row_col_note)s
+
+    Random elements from the distribution are generated either with Boyett's
+    [1]_ or Patefield's algorithm [2]_. Boyett's algorithm has
+    O(N) time and space complexity, where N is the total sum of entries in the
+    table. Patefield's algorithm has O(K x log(N)) time complexity, where K is
+    the number of cells in the table and requires only a small constant work
+    space. By default, the `rvs` method selects the fastest algorithm based on
+    the input, but you can specify the algorithm with the keyword `method`.
+    Allowed values are "boyett" and "patefield".
+
+    .. versionadded:: 1.10.0
+
+    Examples
+    --------
+    >>> from scipy.stats import random_table
+
+    >>> row = [1, 5]
+    >>> col = [2, 3, 1]
+    >>> random_table.mean(row, col)
+    array([[0.33333333, 0.5       , 0.16666667],
+           [1.66666667, 2.5       , 0.83333333]])
+
+    Alternatively, the object may be called (as a function) to fix the row
+    and column vector sums, returning a "frozen" distribution.
+
+    >>> dist = random_table(row, col)
+    >>> dist.rvs(random_state=123)
+    array([[1., 0., 0.],
+           [1., 3., 1.]])
+
+    References
+    ----------
+    .. [1] J. Boyett, AS 144 Appl. Statist. 28 (1979) 329-332
+    .. [2] W.M. Patefield, AS 159 Appl. Statist. 30 (1981) 91-97
+    """
+
+    def __init__(self, seed=None):
+        super().__init__(seed)
+
+    def __call__(self, row, col, *, seed=None):
+        """Create a frozen distribution of tables with given marginals.
+
+        See `random_table_frozen` for more information.
+        """
+        return random_table_frozen(row, col, seed=seed)
+
+    def logpmf(self, x, row, col):
+        """Log-probability of table to occur in the distribution.
+
+        Parameters
+        ----------
+        %(_doc_x)s
+        %(_doc_row_col)s
+
+        Returns
+        -------
+        logpmf : ndarray or scalar
+            Log of the probability mass function evaluated at `x`.
+
+        Notes
+        -----
+        %(_doc_row_col_note)s
+
+        If row and column marginals of `x` do not match `row` and `col`,
+        negative infinity is returned.
+
+        Examples
+        --------
+        >>> from scipy.stats import random_table
+        >>> import numpy as np
+
+        >>> x = [[1, 5, 1], [2, 3, 1]]
+        >>> row = np.sum(x, axis=1)
+        >>> col = np.sum(x, axis=0)
+        >>> random_table.logpmf(x, row, col)
+        -1.6306401200847027
+
+        Alternatively, the object may be called (as a function) to fix the row
+        and column vector sums, returning a "frozen" distribution.
+
+        >>> d = random_table(row, col)
+        >>> d.logpmf(x)
+        -1.6306401200847027
+        """
+        r, c, n = self._process_parameters(row, col)
+        x = np.asarray(x)
+
+        if x.ndim < 2:
+            raise ValueError("`x` must be at least two-dimensional")
+
+        dtype_is_int = np.issubdtype(x.dtype, np.integer)
+        with np.errstate(invalid='ignore'):
+            if not dtype_is_int and not np.all(x.astype(int) == x):
+                raise ValueError("`x` must contain only integral values")
+
+        # x does not contain NaN if we arrive here
+        if np.any(x < 0):
+            raise ValueError("`x` must contain only non-negative values")
+
+        r2 = np.sum(x, axis=-1)
+        c2 = np.sum(x, axis=-2)
+
+        if r2.shape[-1] != len(r):
+            raise ValueError("shape of `x` must agree with `row`")
+
+        if c2.shape[-1] != len(c):
+            raise ValueError("shape of `x` must agree with `col`")
+
+        res = np.empty(x.shape[:-2])
+
+        mask = np.all(r2 == r, axis=-1) & np.all(c2 == c, axis=-1)
+
+        def lnfac(x):
+            return gammaln(x + 1)
+
+        res[mask] = (np.sum(lnfac(r), axis=-1) + np.sum(lnfac(c), axis=-1)
+                     - lnfac(n) - np.sum(lnfac(x[mask]), axis=(-1, -2)))
+        res[~mask] = -np.inf
+
+        return res[()]
+
+    def pmf(self, x, row, col):
+        """Probability of table to occur in the distribution.
+
+        Parameters
+        ----------
+        %(_doc_x)s
+        %(_doc_row_col)s
+
+        Returns
+        -------
+        pmf : ndarray or scalar
+            Probability mass function evaluated at `x`.
+
+        Notes
+        -----
+        %(_doc_row_col_note)s
+
+        If row and column marginals of `x` do not match `row` and `col`,
+        zero is returned.
+
+        Examples
+        --------
+        >>> from scipy.stats import random_table
+        >>> import numpy as np
+
+        >>> x = [[1, 5, 1], [2, 3, 1]]
+        >>> row = np.sum(x, axis=1)
+        >>> col = np.sum(x, axis=0)
+        >>> random_table.pmf(x, row, col)
+        0.19580419580419592
+
+        Alternatively, the object may be called (as a function) to fix the row
+        and column vector sums, returning a "frozen" distribution.
+
+        >>> d = random_table(row, col)
+        >>> d.pmf(x)
+        0.19580419580419592
+        """
+        return np.exp(self.logpmf(x, row, col))
+
+    def mean(self, row, col):
+        """Mean of distribution of conditional tables.
+        %(_doc_mean_params)s
+
+        Returns
+        -------
+        mean: ndarray
+            Mean of the distribution.
+
+        Notes
+        -----
+        %(_doc_row_col_note)s
+
+        Examples
+        --------
+        >>> from scipy.stats import random_table
+
+        >>> row = [1, 5]
+        >>> col = [2, 3, 1]
+        >>> random_table.mean(row, col)
+        array([[0.33333333, 0.5       , 0.16666667],
+               [1.66666667, 2.5       , 0.83333333]])
+
+        Alternatively, the object may be called (as a function) to fix the row
+        and column vector sums, returning a "frozen" distribution.
+
+        >>> d = random_table(row, col)
+        >>> d.mean()
+        array([[0.33333333, 0.5       , 0.16666667],
+               [1.66666667, 2.5       , 0.83333333]])
+        """
+        r, c, n = self._process_parameters(row, col)
+        return np.outer(r, c) / n
+
+    def rvs(self, row, col, *, size=None, method=None, random_state=None):
+        """Draw random tables with fixed column and row marginals.
+
+        Parameters
+        ----------
+        %(_doc_row_col)s
+        size : integer, optional
+            Number of samples to draw (default 1).
+        method : str, optional
+            Which method to use, "boyett" or "patefield". If None (default),
+            selects the fastest method for this input.
+        %(_doc_random_state)s
+
+        Returns
+        -------
+        rvs : ndarray
+            Random 2D tables of shape (`size`, `len(row)`, `len(col)`).
+
+        Notes
+        -----
+        %(_doc_row_col_note)s
+
+        Examples
+        --------
+        >>> from scipy.stats import random_table
+
+        >>> row = [1, 5]
+        >>> col = [2, 3, 1]
+        >>> random_table.rvs(row, col, random_state=123)
+        array([[1., 0., 0.],
+               [1., 3., 1.]])
+
+        Alternatively, the object may be called (as a function) to fix the row
+        and column vector sums, returning a "frozen" distribution.
+
+        >>> d = random_table(row, col)
+        >>> d.rvs(random_state=123)
+        array([[1., 0., 0.],
+               [1., 3., 1.]])
+        """
+        r, c, n = self._process_parameters(row, col)
+        size, shape = self._process_size_shape(size, r, c)
+
+        random_state = self._get_random_state(random_state)
+        meth = self._process_rvs_method(method, r, c, n)
+
+        return meth(r, c, n, size, random_state).reshape(shape)
+
+    @staticmethod
+    def _process_parameters(row, col):
+        """
+        Check that row and column vectors are one-dimensional, that they do
+        not contain negative or non-integer entries, and that the sums over
+        both vectors are equal.
+        """
+        r = np.array(row, dtype=np.int_, copy=True)
+        c = np.array(col, dtype=np.int_, copy=True)
+
+        if np.ndim(r) != 1:
+            raise ValueError("`row` must be one-dimensional")
+        if np.ndim(c) != 1:
+            raise ValueError("`col` must be one-dimensional")
+
+        if np.any(r < 0):
+            raise ValueError("each element of `row` must be non-negative")
+        if np.any(c < 0):
+            raise ValueError("each element of `col` must be non-negative")
+
+        n = np.sum(r)
+        if n != np.sum(c):
+            raise ValueError("sums over `row` and `col` must be equal")
+
+        if not np.all(r == np.asarray(row)):
+            raise ValueError("each element of `row` must be an integer")
+        if not np.all(c == np.asarray(col)):
+            raise ValueError("each element of `col` must be an integer")
+
+        return r, c, n
+
+    @staticmethod
+    def _process_size_shape(size, r, c):
+        """
+        Compute the number of samples to be drawn and the shape of the output
+        """
+        shape = (len(r), len(c))
+
+        if size is None:
+            return 1, shape
+
+        size = np.atleast_1d(size)
+        if not np.issubdtype(size.dtype, np.integer) or np.any(size < 0):
+            raise ValueError("`size` must be a non-negative integer or `None`")
+
+        return np.prod(size), tuple(size) + shape
+
+    @classmethod
+    def _process_rvs_method(cls, method, r, c, n):
+        known_methods = {
+            None: cls._rvs_select(r, c, n),
+            "boyett": cls._rvs_boyett,
+            "patefield": cls._rvs_patefield,
+        }
+        try:
+            return known_methods[method]
+        except KeyError:
+            raise ValueError(f"'{method}' not recognized, "
+                             f"must be one of {set(known_methods)}")
+
+    @classmethod
+    def _rvs_select(cls, r, c, n):
+        # TODO find optimal empirical threshold with benchmarks,
+        # for now we always return "boyett", because "patefield"
+        # is not yet implemented.
+
+        # Example:
+        # k = len(r) * len(c)  # number of cells
+        # if n > fac * np.log(n) * k:
+        #     return cls._rvs_patefield
+        # return cls._rvs_boyett
+        # 'fac' has to be estimated empirically
+        return cls._rvs_boyett
+
+    @staticmethod
+    def _rvs_boyett(row, col, tot, size, random_state):
+        x = np.repeat(np.arange(len(row)), row)
+        y = np.repeat(np.arange(len(col)), col)
+
+        def crosstab(x, y):
+            return contingency.crosstab(x, y).count
+
+        tables = [crosstab(x, random_state.permutation(y))
+                  for i in range(size)]
+        return np.asarray(tables)
+
+    @staticmethod
+    def _rvs_patefield(row, col, tot, size, random_state):
+        # TODO call into C code
+        raise NotImplementedError
+
+
+random_table = random_table_gen()
+
+
+class random_table_frozen(multi_rv_frozen):
+    def __init__(self, row, col, *, seed=None):
+        self._dist = random_table_gen(seed)
+        self._params = self._dist._process_parameters(row, col)
+
+        # monkey patch self._dist
+        def _process_parameters(r, c):
+            return self._params
+        self._dist._process_parameters = _process_parameters
+
+    def logpmf(self, x):
+        return self._dist.logpmf(x, None, None)
+
+    def pmf(self, x):
+        return self._dist.pmf(x, None, None)
+
+    def mean(self):
+        return self._dist.mean(None, None)
+
+    def rvs(self, size=None, method=None, random_state=None):
+        # optimisations are possible here
+        return self._dist.rvs(None, None, size=size, method=method,
+                              random_state=random_state)
+
+
+_ctab_doc_row_col = """\
+row : array_like
+    Sum of table entries in each row.
+col : array_like
+    Sum of table entries in each column."""
+
+_ctab_doc_x = """\
+x : array-like
+   Two-dimensional table of non-negative integers, or a
+   multi-dimensional array with the last two dimensions
+   corresponding with the tables."""
+
+_ctab_doc_row_col_note = """\
+The row and column vectors must be one-dimensional, not empty,
+and each sum up to the same value. They cannot contain negative
+or noninteger entries."""
+
+_ctab_doc_mean_params = f"""
+Parameters
+----------
+{_ctab_doc_row_col}"""
+
+_ctab_doc_row_col_note_frozen = """\
+See class definition for a detailed description of parameters."""
+
+_ctab_docdict = {
+    "_doc_random_state": _doc_random_state,
+    "_doc_row_col": _ctab_doc_row_col,
+    "_doc_x": _ctab_doc_x,
+    "_doc_mean_params": _ctab_doc_mean_params,
+    "_doc_row_col_note": _ctab_doc_row_col_note,
+}
+
+_ctab_docdict_frozen = _ctab_docdict.copy()
+_ctab_docdict_frozen.update({
+    "_doc_row_col": "",
+    "_doc_mean_params": "",
+    "_doc_row_col_note": _ctab_doc_row_col_note_frozen,
+})
+
+
+def _docfill(obj, docdict, template=None):
+    obj.__doc__ = doccer.docformat(template or obj.__doc__, docdict)
+
+
+# Set frozen generator docstrings from corresponding docstrings in
+# random_table and fill in default strings in class docstrings
+_docfill(random_table_gen, _ctab_docdict)
+for name in ['logpmf', 'pmf', 'mean', 'rvs']:
+    method = random_table_gen.__dict__[name]
+    method_frozen = random_table_frozen.__dict__[name]
+    _docfill(method_frozen, _ctab_docdict_frozen, method.__doc__)
+    _docfill(method, _ctab_docdict)


Test patch:
diff --git a/scipy/stats/tests/test_multivariate.py b/scipy/stats/tests/test_multivariate.py
--- a/scipy/stats/tests/test_multivariate.py
+++ b/scipy/stats/tests/test_multivariate.py
@@ -25,7 +25,8 @@
                          random_correlation, unitary_group, dirichlet,
                          beta, wishart, multinomial, invwishart, chi2,
                          invgamma, norm, uniform, ks_2samp, kstest, binom,
-                         hypergeom, multivariate_t, cauchy, normaltest)
+                         hypergeom, multivariate_t, cauchy, normaltest,
+                         random_table)
 from scipy.stats import _covariance, Covariance
 
 from scipy.integrate import romb
@@ -2498,6 +2499,225 @@ def test_invalid_params(self):
                       [10, 15], 5.5)
 
 
+class TestRandomTable:
+    def test_process_parameters(self):
+        message = "`row` must be one-dimensional"
+        with pytest.raises(ValueError, match=message):
+            random_table([[1, 2]], [1, 2])
+
+        message = "`col` must be one-dimensional"
+        with pytest.raises(ValueError, match=message):
+            random_table([1, 2], [[1, 2]])
+
+        message = "each element of `row` must be non-negative"
+        with pytest.raises(ValueError, match=message):
+            random_table([1, -1], [1, 2])
+
+        message = "each element of `col` must be non-negative"
+        with pytest.raises(ValueError, match=message):
+            random_table([1, 2], [1, -2])
+
+        message = "sums over `row` and `col` must be equal"
+        with pytest.raises(ValueError, match=message):
+            random_table([1, 2], [1, 0])
+
+        message = "each element of `row` must be an integer"
+        with pytest.raises(ValueError, match=message):
+            random_table([2.1, 2.1], [1, 1, 2])
+
+        message = "each element of `col` must be an integer"
+        with pytest.raises(ValueError, match=message):
+            random_table([1, 2], [1.1, 1.1, 1])
+
+        row = [1, 3]
+        col = [2, 1, 1]
+        r, c, n = random_table._process_parameters([1, 3], [2, 1, 1])
+        assert_equal(row, r)
+        assert_equal(col, c)
+        assert n == np.sum(row)
+
+    def test_process_rvs_method_on_None(self):
+        # TODO also test with a case where "patefield" is selected
+        row = [1, 3]
+        col = [2, 1, 1]
+
+        ct = random_table
+        expected = ct.rvs(row, col, method="boyett", random_state=1)
+        got = ct.rvs(row, col, method=None, random_state=1)
+
+        assert_equal(expected, got)
+
+    def test_rvs_method(self):
+        pytest.xfail("patefield will be implemented in follow-up PR")
+
+        row = [1, 3]
+        col = [2, 1, 1]
+
+        ct = random_table
+        rvs1 = ct.rvs(row, col, size=1000, method="boyett", random_state=1)
+        rvs2 = ct.rvs(row, col, size=1000, method="patefield", random_state=1)
+
+        tab1, count1 = np.unique(rvs1, axis=0, return_counts=True)
+        tab2, count2 = np.unique(rvs2, axis=0, return_counts=True)
+
+        assert_equal(tab1, tab2)
+        assert_allclose(count1, count2)
+
+    def test_process_rvs_method_bad_argument(self):
+        row = [1, 3]
+        col = [2, 1, 1]
+
+        # order of items in set is random, so cannot check that
+        message = "'foo' not recognized, must be one of"
+        with pytest.raises(ValueError, match=message):
+            random_table.rvs(row, col, method="foo")
+
+        with pytest.raises(NotImplementedError):
+            random_table.rvs(row, col, method="patefield")
+
+    @pytest.mark.parametrize('frozen', (True, False))
+    @pytest.mark.parametrize('log', (True, False))
+    def test_pmf_logpmf(self, frozen, log):
+        rng = np.random.default_rng(628174795866951638)
+        row = [2, 6]
+        col = [1, 3, 4]
+        rvs = random_table.rvs(row, col, size=1000, random_state=rng)
+
+        obj = random_table(row, col) if frozen else random_table
+        method = getattr(obj, "logpmf" if log else "pmf")
+        if not frozen:
+            original_method = method
+
+            def method(x):
+                return original_method(x, row, col)
+        pmf = (lambda x: np.exp(method(x))) if log else method
+
+        unique_rvs, counts = np.unique(rvs, axis=0, return_counts=True)
+
+        # rough accuracy check
+        p = pmf(unique_rvs)
+        assert_allclose(p * len(rvs), counts, rtol=0.1)
+
+        # accept any iterable
+        p2 = pmf(list(unique_rvs[0]))
+        assert_equal(p2, p[0])
+
+        # accept high-dimensional input and 2d input
+        rvs_nd = rvs.reshape((10, 100) + rvs.shape[1:])
+        p = pmf(rvs_nd)
+        assert p.shape == (10, 100)
+        for i in range(p.shape[0]):
+            for j in range(p.shape[1]):
+                pij = p[i, j]
+                rvij = rvs_nd[i, j]
+                qij = pmf(rvij)
+                assert_equal(pij, qij)
+
+        # probability is zero if column marginal does not match
+        x = [[0, 1, 1], [2, 1, 3]]
+        assert_equal(np.sum(x, axis=-1), row)
+        p = pmf(x)
+        assert p == 0
+
+        # probability is zero if row marginal does not match
+        x = [[0, 1, 2], [1, 2, 2]]
+        assert_equal(np.sum(x, axis=-2), col)
+        p = pmf(x)
+        assert p == 0
+
+        # response to invalid inputs
+        message = "`x` must be at least two-dimensional"
+        with pytest.raises(ValueError, match=message):
+            pmf([1])
+
+        message = "`x` must contain only integral values"
+        with pytest.raises(ValueError, match=message):
+            pmf([[1.1]])
+
+        message = "`x` must contain only integral values"
+        with pytest.raises(ValueError, match=message):
+            pmf([[np.nan]])
+
+        message = "`x` must contain only non-negative values"
+        with pytest.raises(ValueError, match=message):
+            pmf([[-1]])
+
+        message = "shape of `x` must agree with `row`"
+        with pytest.raises(ValueError, match=message):
+            pmf([[1, 2, 3]])
+
+        message = "shape of `x` must agree with `col`"
+        with pytest.raises(ValueError, match=message):
+            pmf([[1, 2],
+                 [3, 4]])
+
+    def test_rvs_mean(self):
+        # test if `rvs` is unbiased and large sample size converges
+        # to the true mean. `test_pmf` also implicitly tests `rvs`.
+        rng = np.random.default_rng(628174795866951638)
+
+        row = [2, 6]
+        col = [1, 3, 4]
+        rvs = random_table.rvs(row, col, size=1000, random_state=rng)
+        mean = random_table.mean(row, col)
+        assert_equal(np.sum(mean), np.sum(row))
+        assert_allclose(rvs.mean(0), mean, atol=0.05)
+        assert_equal(rvs.sum(axis=-1), np.broadcast_to(row, (1000, 2)))
+        assert_equal(rvs.sum(axis=-2), np.broadcast_to(col, (1000, 3)))
+
+    def test_rvs_size(self):
+        row = [2, 6]
+        col = [1, 3, 4]
+
+        # test size `None`
+        rng = np.random.default_rng(628174795866951638)
+        rv = random_table.rvs(row, col, random_state=rng)
+        assert rv.shape == (2, 3)
+
+        # test size 1
+        rng = np.random.default_rng(628174795866951638)
+        rv2 = random_table.rvs(row, col, size=1, random_state=rng)
+        assert rv2.shape == (1, 2, 3)
+        assert_equal(rv, rv2[0])
+
+        # test size 0
+        rv3 = random_table.rvs(row, col, size=0, random_state=rng)
+        assert rv3.shape == (0, 2, 3)
+
+        # test other valid size
+        rng = np.random.default_rng(628174795866951638)
+        rv4 = random_table.rvs(row, col, size=20, random_state=rng)
+        assert rv4.shape == (20, 2, 3)
+
+        rng = np.random.default_rng(628174795866951638)
+        rv5 = random_table.rvs(row, col, size=(4, 5), random_state=rng)
+        assert rv5.shape == (4, 5, 2, 3)
+
+        assert_allclose(rv5.reshape(20, 2, 3), rv4, rtol=1e-15)
+
+        # test invalid size
+        message = "`size` must be a non-negative integer or `None`"
+        with pytest.raises(ValueError, match=message):
+            random_table.rvs(row, col, size=-1, random_state=rng)
+
+        with pytest.raises(ValueError, match=message):
+            random_table.rvs(row, col, size=np.nan, random_state=rng)
+
+    def test_frozen(self):
+        rng1 = np.random.default_rng(628174795866951638)
+        rng2 = np.random.default_rng(628174795866951638)
+
+        row = [2, 6]
+        col = [1, 3, 4]
+        d = random_table(row, col, seed=rng1)
+        expected = random_table.mean(row, col)
+        assert_equal(expected, d.mean())
+
+        expected = random_table.rvs(row, col, size=10, random_state=rng2)
+        got = d.rvs(size=10)
+        assert_equal(expected, got)
+
+
 def check_pickling(distfn, args):
     # check that a distribution instance pickles and unpickles
     # pay special attention to the random_state property