Module ateams.models

Models

A model programmatically specifies a probability distribution over a combinatorial object like a graph, cubical complex, or polytope. Given one of these objects (e.g. Cubical), each model implements a proposal function for sampling from the distribution it specifies. For example, SwendsenWang represents the Edwards-Sokal coupling of the plaquette random cluster model and Potts lattice gauge theory on subcomplexes of the $d$-fold, scale-$N$ cubical torus $\mathbb T^d_N$; its proposal function is a generalized version of the Swendsen-Wang algorithm.

The SwendsenWang, InvadedCluster, and Nienhuis models require performant sparse linear algebra over finite fields, for which we turn to LinBox. The InvadedCluster and Bernoulli models require fast computation of persistent homology, for which we use PHAT (when sampling over $\mathbb Z/2\mathbb Z$) and a home-brew variant of the "twist" algorithm proposed by Chen and Kerber. Only the Glauber dynamics model is implemented in "pure Python," though it relies exclusively on NumPy.

Classes

class Bernoulli (C, dimension=1, PHAT=True)

Initializes classic Bernoulli percolation on the provided complex, detecting percolation in the dimension-1th homology group.

Args

C : Complex
The Complex object on which we'll be running experiments.
dimension : int=1
The dimension of cells on which we're percolating.
PHAT : bool=True
Uses fast PHAT routines instead of slow inbuilt ones. WARNING: using inbuilt methods may dramatically increase computation time.
Expand source code
class Bernoulli():
        _name = "Bernoulli"
        
        def __init__(self, C, dimension=1, PHAT=True):
                """
                Initializes classic Bernoulli percolation on the provided complex,
                detecting percolation in the `dimension`-1th homology group.

                Args:
                        C (Complex): The `Complex` object on which we'll be running experiments.
                        dimension (int=1): The dimension of cells on which we're percolating.
                        PHAT (bool=True): Uses fast PHAT routines instead of slow inbuilt
                                ones. WARNING: using inbuilt methods may dramatically increase
                                computation time.
                """
                # Object access. Set a field.
                self.complex = C
                self.dimension = dimension
                self._returns = 1
                self.field = 2

                # Set a phantom spins attribute so we don't break the Chain
                self.spins = None


                # Force-recompute the matrices for a different dimension; creates
                # a set of orientations for fast elementwise products.
                self.matrices = Matrices()
                self.matrices.full = self.complex.matrices.full

                boundary, coboundary = self.complex.recomputeBoundaryMatrices(dimension)
                self.matrices.boundary = boundary
                self.matrices.coboundary = coboundary

                # Useful values to have later.
                self.cellCount = len(self.complex.flattened)
                self.cells = len(self.complex.Boundary[self.dimension])
                self.faces = len(self.complex.Boundary[self.dimension-1])
                self.target = np.arange(self.complex.breaks[self.dimension], self.complex.breaks[self.dimension+1])

                # Premake the "occupied cells" array; change the dimension of the complex
                # to correspond to the provided dimension.
                self.rank = comb(len(self.complex.corners), self.dimension)
                self.nullity = len(self.complex.Boundary[self.dimension])

                # Delegates computation for persistence.
                self._delegateComputation(PHAT)

                # Seed the random number generator.
                self.RNG = np.random.default_rng()

        
        def _delegateComputation(self, PHAT):
                if PHAT:
                        low, high = self.complex.breaks[self.dimension], self.complex.breaks[self.dimension+1]
                        times = set(range(self.cellCount))

                        def whittle(pairs):
                                _births, _deaths = zip(*pairs)
                                births = set(_births)
                                deaths = set(_deaths)

                                return set(
                                        e for e in times-(births|deaths)
                                        if low <= e < high
                                )

                        def persist(filtration):
                                essential = ComputePersistencePairs(
                                        self.matrices.full, filtration, self.dimension, self.complex.breaks
                                )

                                return whittle(essential)
                        
                else:
                        # If we can't/don't want to use LinBox, use inbuilt methods.
                        Persistencer = Persistence(2, self.complex.flattened, self.dimension)
                        
                        def persist(filtration):
                                return Persistencer.TwistComputePercolationEvents(filtration)

                self.persist = persist


        def _filtrate(self):
                """
                Constructs a filtration based on the evaluation of the cochain.
                """
                # Find which cubes get zeroed out (i.e. are sent to zero by the cocycle).
                low = self.complex.breaks[self.dimension]
                high = self.complex.breaks[self.dimension+1]

                filtration = np.arange(self.cellCount)
                shuffled = np.random.permutation(self.target)
                filtration[low:high] = shuffled

                return filtration, shuffled-low


        def _initial(self):
                """
                Computes an initial state for the model's Complex.

                Returns:
                        A numpy `np.array` representing a vector of spin assignments.
                """
                return self.RNG.integers(
                        0, high=self.field, dtype=FINT, size=self.faces
                )
        

        def _proposal(self, time):
                """
                Proposal scheme for generalized invaded-cluster evolution on the
                random-cluster model.

                Args:
                        time (int): Step in the chain.

                Returns:
                        A numpy array representing a vector of spin assignments.
                """
                # Construct the filtration and find the essential cycles.
                filtration, shuffled = self._filtrate()
                essential = self.persist(filtration)

                j = 0
                low = self.complex.breaks[self.dimension]

                occupied = np.zeros((self.rank, self.nullity))

                for t in essential:
                        occupiedIndices = shuffled[:t-low]
                        occupied[j,occupiedIndices] = 1
                        j += 1

                return occupied
        

        def _assign(self, cocycle): pass
class Glauber (C,
dimension=1,
field=2,
temperature=<function constant.<locals>._>,
initial=None)

Initializes Glauber dynamics on the Potts model.

Args

C
The Complex object on which we'll be running experiments.
dimension : int=1
Dimension on which we'll be building experiments.
field : int=2
Field characteristic.
temperature : Callable
A temperature schedule function which takes a single positive integer argument t, and returns the scheduled temperature at time t.
initial : np.ndarray
A vector of spin assignments to components.
Expand source code
class Glauber():
        _name = "Glauber"
        
        def __init__(
                        self, C, dimension=1, field=2, temperature=constant(-0.6), initial=None
                ):
                """
                Initializes Glauber dynamics on the Potts model.

                Args:
                        C: The `Complex` object on which we'll be running experiments.
                        dimension (int=1): Dimension on which we'll be building experiments.
                        field (int=2): Field characteristic.
                        temperature (Callable): A temperature schedule function which
                                takes a single positive integer argument `t`, and returns the
                                scheduled temperature at time `t`.
                        initial (np.ndarray): A vector of spin assignments to components.
                """
                self.field = field
                self.complex = C
                self.dimension = dimension
                self.temperature = temperature
                self._returns = 2

                # Useful values to have later.
                self.cells = len(self.complex.Boundary[self.dimension])
                self.faces = len(self.complex.Boundary[self.dimension-1])
                self.closed = 1

                # Seed the random number generator.
                self.RNG = np.random.default_rng()

                # If no initial spin configuration is passed, initialize.
                if not initial: self.spins = self._initial()
                else: self.spins = (initial%self.field).astype(FINT)
        

        def _initial(self):
                """
                Computes an initial state for the model's Complex.

                Returns:
                        A numpy `np.array` representing a vector of spin assignments.
                """
                return self.RNG.integers(
                        0, high=self.field, dtype=MINT, size=self.faces
                )
        

        def _proposal(self, time):
                """
                Proposal scheme for generalized Glauber dynamics on the Potts model:
                uniformly randomly chooses a face in the complex, flips the face's spin,
                and returns the corresponding cocycle.

                Args:
                        time (int): Step in the chain.

                Returns:
                        A NumPy array representing a vector of spin assignments.
                """
                # Flip.
                spins = self.spins[::]
                flip = self.RNG.integers(self.faces)
                spins[flip] = (self.RNG.integers(self.field, dtype=FINT))

                boundary = self.complex.Boundary[self.dimension]
                q = self.field

                # Evaluate the current spin assignment (cochain).
                evaluation = spins[boundary]
                evaluation[:, 1::2] = -evaluation[:, 1::2]%q
                sums = evaluation.sum(axis=1)%q

                opens = np.nonzero(sums == 0)[0]
                closed = -(self.faces-opens.shape[0])

                # If we don't know how many "closed" faces there are, compute it.
                if self.closed > 0:
                        evaluation = self.spins[boundary]
                        evaluation[:,1::2] = -evaluation[:,1::2]%q
                        sums = evaluation.sum(axis=1)%q
                        
                        opens = np.nonzero(sums == 0)[0]
                        self.closed = -(self.faces-opens.shape[0])

                # Compute the energy and decide if this is an acceptable transition.
                energy = np.exp(self.temperature(time)*(self.closed-closed))

                if self.RNG.uniform() < min(1, energy):
                        self.closed = closed
                        self.open = opens
                        self.spins = spins

                satisfied = np.zeros(self.cells, dtype=FINT)
                satisfied[self.open] = 1

                return self.spins, satisfied
        

        def _assign(self, cochain):
                """
                Updates mappings from faces to spins and cubes to occupations.
                
                Args:
                        cochain (np.array): Cochain on the complex.
                
                Returns:
                        None.
                """
                self.spins = cochain
class InvadedCluster (C,
dimension=1,
field=2,
initial=None,
stop=<function InvadedCluster.<lambda>>,
LinBox=True,
maxTries=16,
parallel=False,
minBlockSize=32,
maxBlockSize=64,
cores=4)

Initializes the plaquette invaded-cluster algorithm on the provided integer complex, detecting percolation in the homology-th homology group.

Args

C : Complex
The Complex object on which we'll be running experiments.
dimension : int=1
The dimension of cells on which we're percolating.
field : int=2
Field characteristic.
initial : np.array
A vector of spin assignments to components.
stop : function
A function that returns the number of essential cycles found before sampling the next configuration.
LinBox : bool=True
Uses fast LinBox routines instead of slow inbuilt ones. WARNING: using inbuilt methods may dramatically increase computation time.
maxTries : int=16
The number of attempts LinBox makes to sample a nonzero vector in the kernel of the coboundary matrix A. See more discussion in the SwendsenWang model.
parallel : boolean
Should matrix computations be done in parallel? (Uses C/C++).
minBlockSize : int=32
If parallel is truthy, this is the smallest number of columns processed in parallel.
maxBlockSize : int=64
If parallel is truthy, this is the largest number of columns processed in parallel.
cores : int=4
Number of available CPUs/cores/threads on the machine.

Included below are performance statistics for various configurations of InvadedCluster. Each configuration completed 100 iterations on Pangolin, a Dell Precision 5280 workstation with an 18-core@1.305GhZ Intel Xeon W-2295 CPU. Warns indicates the number of NumericalInstabilityWarnings caught during the run, and Zeros the number of all-zeros vectors returned.



Samples in \mathbb T^2_N with PHAT

s/iteration
Field $N$ maxTries
$\mathbb Z/2\mathbb Z$ 724 8 2.155
16 2.255
32 2.161
1024 8 4.921
16 4.663
32 4.915
1448 8 10.527
16 10.442
32 10.658
Expand source code
class InvadedCluster():
        _name = "InvadedCluster"
        
        def __init__(
                        self, C, dimension=1, field=2, initial=None, stop=lambda: 1, LinBox=True, maxTries=16,
                        parallel=False, minBlockSize=32, maxBlockSize=64, cores=4
                ):
                """
                Initializes the plaquette invaded-cluster algorithm on the provided
                integer complex, detecting percolation in the `homology`-th homology
                group.

                Args:
                        C (Complex): The `Complex` object on which we'll be running experiments.
                        dimension (int=1): The dimension of cells on which we're percolating.
                        field (int=2): Field characteristic.
                        initial (np.array): A vector of spin assignments to components.
                        stop (function): A function that returns the number of essential cycles
                                found before sampling the next configuration.
                        LinBox (bool=True): Uses fast LinBox routines instead of slow inbuilt
                                ones. WARNING: using inbuilt methods may dramatically increase
                                computation time.
                        maxTries (int=16): The number of attempts LinBox makes to sample a nonzero
                                vector in the kernel of the coboundary matrix \(A\). See more
                                discussion in the `SwendsenWang` model.
                        parallel (boolean): Should matrix computations be done in parallel? (Uses C/C++).
                        minBlockSize (int=32): If `parallel` is truthy, this is the smallest
                                number of columns processed in parallel.
                        maxBlockSize (int=64): If `parallel` is truthy, this is the largest
                                number of columns processed in parallel.
                        cores (int=4): Number of available CPUs/cores/threads on the machine.

                Included below are performance statistics for various configurations of
                `InvadedCluster`. Each configuration completed 100 iterations on Pangolin,
                a Dell Precision 5280 workstation with an 18-core@1.305GhZ Intel Xeon W-2295
                CPU. **Warns** indicates the number of `NumericalInstabilityWarning`s caught
                during the run, and **Zeros** the number of all-zeros vectors returned.
                
                </br>
                </br>
                <center> <strong> Samples in \(\mathbb T^2_N\) with PHAT </strong> </center>
                ..include:: ./tables/InvadedCluster.PHATComputePersistencePairs.2.html
                """
                # Object access.
                self.complex = C
                self.dimension = dimension
                self.stop = stop
                self._returns = 3
                self.field = field


                # Force-recompute the matrices for a different dimension; creates
                # a set of orientations for fast elementwise products.
                self.matrices = Matrices()
                self.matrices.full = self.complex.matrices.full

                boundary, coboundary = self.complex.recomputeBoundaryMatrices(dimension)
                self.matrices.boundary = boundary
                self.matrices.coboundary = coboundary


                # Useful values to have later.
                self.cellCount = len(self.complex.flattened)
                self.cells = len(self.complex.Boundary[self.dimension])
                self.faces = len(self.complex.Boundary[self.dimension-1])
                self.target = np.arange(self.complex.breaks[self.dimension], self.complex.breaks[self.dimension+1])


                # Check the dimensions of the boundary/coboundary matrices by comparing
                # the number of cells. LinBox is really sensitive to smaller-size matrices,
                # but can easily handle large ones.
                if self.cells*self.faces < 10000 and LinBox:
                        warnings.warn(f"complex with {self.cells*self.faces} boundary matrix entries is too small for accurate matrix solves; may segfault.", TooSmallWarning, stacklevel=2)


                # Premake the "occupied cells" array; change the dimension of the complex
                # to correspond to the provided dimension.
                self.rank = comb(len(self.complex.corners), self.dimension)
                self.nullity = len(self.complex.Boundary[self.dimension])


                # Delegates computation for persistence and cocycle sampling.
                self._delegateComputation(LinBox, parallel, minBlockSize, maxBlockSize, cores, maxTries)


                # Seed the random number generator.
                self.RNG = np.random.default_rng()


                # If no initial spin configuration is passed, initialize.
                if not initial: self.spins = self._initial()
                else: self.spins = (initial%self.field).astype(FINT)

        
        def _delegateComputation(self, LinBox, parallel, minBlockSize, maxBlockSize, cores, maxTries):
                low, high = self.complex.breaks[self.dimension], self.complex.breaks[self.dimension+1]

                # If we're using LinBox, our sampling method is Lanczos regardless of
                # dimension.
                if LinBox:
                        def sample(zeros):
                                try:
                                        return np.array(LanczosKernelSample(
                                                self.matrices.coboundary, zeros, 2*self.dimension,
                                                self.faces, self.field, maxTries
                                        ), dtype=FINT)
                                except Exception as e:
                                        raise NumericalInstabilityWarning(e)
                
                # If we're using LinBox and the characteristic of our field is greater
                # than 2, we use the twist_reduce variant implemented in this library.
                if LinBox and self.field > 2:
                        def persist(filtration):
                                essential = ComputePercolationEvents(
                                        self.matrices.full, filtration, self.dimension,
                                        self.field, self.complex.breaks
                                )

                                essential = np.array(list(essential))
                                essential.sort()
                                
                                return essential[(essential >= low) & (essential < high)]
                
                # If we're using LinBox and the field we're computing over *is* two,
                # use PHAT.
                elif LinBox and self.field < 3:
                        times = set(range(self.cellCount))

                        def whittle(pairs):
                                _births, _deaths = zip(*pairs)
                                births = set(_births)
                                deaths = set(_deaths)

                                return set(
                                        e for e in times-(births|deaths)
                                        if low <= e < high
                                )
                        
                        def persist(filtration):
                                essential = ComputePersistencePairs(
                                        self.matrices.full, filtration, self.dimension, self.complex.breaks
                                )

                                return whittle(essential)
                        
                if not LinBox:
                        # If we can't/don't want to use LinBox, use inbuilt methods.
                        Reducer = MatrixReduction(self.field, parallel, minBlockSize, maxBlockSize, cores)
                        Persistencer = Persistence(self.field, self.complex.flattened, self.dimension)

                        coboundary = np.zeros((self.cells, self.faces), dtype=FINT)
                        rows = self.matrices.coboundary[::3]
                        cols = self.matrices.coboundary[1::3]
                        entries = (self.matrices.coboundary[2::3]%self.field).astype(FINT)

                        coboundary[rows,cols] = entries
                        
                        def persist(filtration):
                                return Persistencer.TwistComputePercolationEvents(filtration)
                        
                        def sample(zeros):
                                return KernelSample(Reducer, coboundary.take(zeros, axis=0)).astype(FINT)
                

                self.sample = sample
                self.persist = persist


        def _filtrate(self, cochain):
                """
                Constructs a filtration based on the evaluation of the cochain.
                """
                # Find which cubes get zeroed out (i.e. are sent to zero by the cocycle).
                boundary = self.complex.Boundary[self.dimension]
                q = self.field
                
                coefficients = cochain[boundary]
                coefficients[:,1::2] = -coefficients[:,1::2]%q
                sums = coefficients.sum(axis=1)%q

                # POSSIBLY INEFFICIENT!! TAKE A LOOK DUMMY
                satisfied = np.nonzero(sums==0)[0]
                unsatisfied = np.nonzero(sums>0)[0]
                m = satisfied.shape[0]

                # Construct the filtration.
                filtration = np.arange(self.cellCount)
                low = self.complex.breaks[self.dimension]
                high = self.complex.breaks[self.dimension+1]

                shuffled = np.random.permutation(satisfied)
                filtration[low:low+m] = self.target[shuffled]
                filtration[low+m:high] = self.target[unsatisfied]

                return filtration, shuffled, satisfied


        def _initial(self):
                """
                Computes an initial state for the model's Complex.

                Returns:
                        A numpy `np.array` representing a vector of spin assignments.
                """
                return self.RNG.integers(
                        0, high=self.field, dtype=FINT, size=self.faces
                )
        

        def _proposal(self, time):
                """
                Proposal scheme for generalized invaded-cluster evolution on the
                random-cluster model.

                Args:
                        time (int): Step in the chain.

                Returns:
                        A numpy array representing a vector of spin assignments.
                """
                # Construct the filtration and find the essential cycles.
                filtration, shuffledIndices, satisfiedIndices = self._filtrate(self.spins)
                essential = self.persist(filtration)

                j = 0
                low = self.complex.breaks[self.dimension]

                stop = self.stop()
                occupied = np.zeros((self.rank, self.nullity))
                satisfied = np.zeros(self.nullity)

                for t in essential:
                        occupiedIndices = shuffledIndices[:t-low]
                        occupied[j,occupiedIndices] = 1

                        if (j+1) == stop: spins = self.sample(occupiedIndices)

                        j += 1

                satisfied[satisfiedIndices] = 1

                return spins, occupied, satisfied
        

        def _assign(self, cocycle):
                """
                Updates mappings from faces to spins and cubes to occupations.

                Args:
                        cocycle (np.array): Cocycle on the subcomplex.
                
                Returns:
                        Nothing.
                """
                self.spins = cocycle
class Nienhuis (C,
q1,
q2,
dimension=2,
field=2,
initial=None,
LinBox=True,
maxTries=16,
parallel=False,
minBlockSize=32,
maxBlockSize=64,
cores=4)

Initializes the self-dual Nienhuis model.

Args

C : Complex
The Complex object on which we'll be running experiments. Here, the dimension is assumed to be 2.
q1 : float
Edge coupling parameter.
q2 : float
Face (i.e. cell) coupling parameter.
dimension : int
Dimension targeted by the model.
field : int=2
Field characteristic.
initial : np.array
A vector of spin assignments to components.
LinBox : bool=True
Uses fast LinBox routines instead of slow inbuilt ones. WARNING: using inbuilt methods may dramatically increase computation time.
maxTries : int=16
The number of attempts LinBox makes to sample a nonzero vector in the kernel of the coboundary matrix.
parallel : boolean
Should matrix computations be done in parallel? (Uses C/C++).
minBlockSize : int=32
If parallel is truthy, this is the smallest number of columns processed in parallel.
maxBlockSize : int=64
If parallel is truthy, this is the largest number of columns processed in parallel.
cores : int=4
Number of available CPUs/cores/threads on the machine.
Expand source code
class Nienhuis():
        _name = "Nienhuis"

        def __init__(
                        self, C, q1, q2, dimension=2, field=2, initial=None, LinBox=True, maxTries=16,
                        parallel=False, minBlockSize=32, maxBlockSize=64, cores=4
                ):
                """
                Initializes the self-dual Nienhuis model.

                Args:
                        C (Complex): The `Complex` object on which we'll be running experiments.
                                Here, the dimension is assumed to be 2.
                        q1 (float): Edge coupling parameter.
                        q2 (float): Face (i.e. cell) coupling parameter.
                        dimension (int): Dimension targeted by the model.
                        field (int=2): Field characteristic.
                        initial (np.array): A vector of spin assignments to components.
                        LinBox (bool=True): Uses fast LinBox routines instead of slow inbuilt
                                ones. WARNING: using inbuilt methods may dramatically increase
                                computation time.
                        maxTries (int=16): The number of attempts LinBox makes to sample a nonzero
                                vector in the kernel of the coboundary matrix.
                        parallel (boolean): Should matrix computations be done in parallel? (Uses C/C++).
                        minBlockSize (int=32): If `parallel` is truthy, this is the smallest
                                number of columns processed in parallel.
                        maxBlockSize (int=64): If `parallel` is truthy, this is the largest
                                number of columns processed in parallel.
                        cores (int=4): Number of available CPUs/cores/threads on the machine.
                """
                # Object access.
                self.complex = C
                self.dimension = dimension
                self._returns = 3
                self.field = field

                # Force-recompute the matrices for a different dimension; creates
                # a set of orientations for fast elementwise products. Here, the dimension
                # is implicitly assumed to be 2.
                self.matrices = Matrices()
                self.matrices.full = self.complex.matrices.full

                boundary, coboundary = self.complex.recomputeBoundaryMatrices(self.dimension)
                self.matrices.boundary = boundary
                self.matrices.coboundary = coboundary

                # Useful values to have later.
                self.cells = len(self.complex.Boundary[self.dimension])
                self.faces = len(self.complex.Boundary[self.dimension-1])
                self.faceIndices = np.arange(self.faces)

                self.orientations = Bunch()
                self.orientations.cells = np.tile([-1,1], self.dimension).astype(FINT)
                self.orientations.faces = np.tile([-1,1], self.dimension-1).astype(FINT)

                # Coupling parameters.
                self.coupling = Bunch()
                self.coupling.face = q1
                self.coupling.cell = q2

                self.prob = Bunch()
                self.prob.face = self.coupling.face/(1+self.coupling.face)
                self.prob.cell = self.coupling.cell/(1+self.coupling.cell)


                # Check the dimensions of the boundary/coboundary matrices by comparing
                # the number of cells. LinBox is really sensitive to smaller-size matrices,
                # but can easily handle large ones.
                if self.cells*self.faces < 10000 and LinBox:
                        warnings.warn(f"complex with {self.cells*self.faces} boundary matrix entries is too small for accurate matrix solves; may segfault.", TooSmallWarning, stacklevel=2)

                # Seed the random number generator.
                self.RNG = np.random.default_rng()

                # If no initial spin configuration is passed, initialize.
                if not initial: self.spins = self._initial()
                else: self.spins = (initial%self.field).astype(FINT)

                # Delegate computation.
                self._delegateComputation(LinBox, parallel, minBlockSize, maxBlockSize, cores, maxTries)


        def _delegateComputation(self, LinBox, parallel, minBlockSize, maxBlockSize, cores, maxTries):
                # If we use LinBox, keep everything as-is.
                if LinBox:
                        def sample(faceZeros, cellZeros):
                                try:
                                        return np.array(SubLanczosKernelSample(
                                                self.matrices.coboundary, cellZeros, faceZeros, self.field, maxTries=maxTries
                                        ), dtype=FINT)
                                except Exception as e:
                                        raise NumericalInstabilityWarning(e)
                
                # If we use inbuilt routines, we need to actually construct the matrix
                # form of the coboundary matrix, which is extremely sparse (and really big).
                # Makes things slow.
                else:
                        coboundary = np.zeros((self.cells, self.faces), dtype=FINT)
                        rows = self.matrices.coboundary[::3]
                        cols = self.matrices.coboundary[1::3]
                        entries = (self.matrices.coboundary[2::3]%self.field).astype(FINT)

                        coboundary[rows,cols] = entries
                        Reducer = MatrixReduction(self.field, parallel, minBlockSize, maxBlockSize, cores)

                        def sample(faceZeros, cellZeros):
                                subbasis = coboundary.take(cellZeros, axis=0).take(faceZeros, axis=1)
                                return KernelSample(Reducer, subbasis).astype(FINT)
                        
                self.sample = sample


        def _initial(self):
                """
                Computes an initial state for the model's Complex.

                Returns:
                        A numpy array of spin assignments (mod p).
                """
                return self.RNG.integers(
                        0, high=self.field, dtype=FINT, size=self.faces
                )
        

        def _proposal(self, time):
                """
                Proposal scheme for the Nienhuis model.

                Args:
                        time (int): Step in the chain.

                Returns:
                        A numpy array of spin assignments (mod p).
                """
                # Choose which cells and faces to include.
                cells = self.RNG.uniform(size=self.cells)
                faces = self.RNG.uniform(size=self.faces)
                threshCells = (cells < self.prob.cell).nonzero()[0]
                threshFaces = (faces < self.prob.face).nonzero()[0]
                q = self.field

                # Evaluate the current spin configuration (cochain) on the boundary
                # of our chosen cells, and find which to include.
                cellBoundary = self.complex.Boundary[self.dimension]
                cellCoefficients = self.spins[cellBoundary]
                cellCoefficients[:,1::2] = -cellCoefficients[:,1::2]%q
                cellSums = cellCoefficients.sum(axis=1)%q
                cellZeros = np.nonzero(cellSums[threshCells] == 0)[0]

                # Decide which faces (by default, edges) to include, take a submatrix
                # of the coboundary matrix by including only the rows corresponding to
                # included cells and columns corresponding to included faces, then
                # sample new spins.
                faceCoefficients = np.intersect1d((self.spins==0).nonzero()[0], threshFaces)
                faceZeros = np.setdiff1d(self.faceIndices, threshFaces)
                subspins = self.sample(faceZeros, cellZeros)

                # Organize spin data.
                spins = self.spins.copy()
                spins[faceZeros] = subspins
                spins[faceCoefficients] = 0

                # Compute energies.
                faceEnergy = (self.faces - np.count_nonzero(spins==0))/self.faces
                cellEnergy = (self.cells - np.count_nonzero(cellSums==0))/self.cells

                return spins, faceEnergy, cellEnergy


        def _assign(self, cocycle):
                """
                Updates mappings from faces to spins and cubes to occupations.

                Args:
                        cocycle (np.array): Cocycle on the subcomplex.
                
                Returns:
                        None.
                """
                self.spins = cocycle
class SwendsenWang (C,
dimension=1,
field=2,
temperature=<function constant.<locals>._>,
initial=None,
LinBox=True,
parallel=False,
minBlockSize=32,
maxBlockSize=64,
cores=4,
maxTries=16)

Initializes Swendsen-Wang evolution on the Potts model.

Args

C : Complex
The Complex object on which we'll be running experiments.
dimension : int=1
The dimension of cells on which we're percolating.
field : int=2
Field characteristic.
temperature : Callable
A temperature schedule function which takes a single positive integer argument t, and returns the scheduled temperature at time t.
initial : np.array
A vector of spin assignments to components.
LinBox : bool=True
Uses fast LinBox routines instead of slow inbuilt ones. WARNING: using inbuilt methods may dramatically increase computation time.
parallel : bool=False
Should matrix computations be done in parallel? (Uses C/C++).
minBlockSize : int=32
If parallel is truthy, this is the smallest number of columns processed in parallel.
maxBlockSize : int=64
If parallel is truthy, this is the largest number of columns processed in parallel.
cores : int=4
Number of available CPUs/cores/threads on the machine.
maxTries : int=16
The number of attempts LinBox makes to sample a nonzero vector in the kernel of the coboundary matrix.

If the first vector sampled by the Lanczos algorithm is all zeros, we perform the following steps until a nonzero vector is found, or we exhaust the number of attempts:

  1. sample once from A with no preconditioner;
  2. sample up to twice from D_0A, where D_0 is a random diagonal matrix;
  3. sample up to twice from A^\top D_1 A, where D_1 is a random diagonal matrix;
  4. sample for the remainder of the attempts from D_2 A^\top D_3 A D_2, where D_2, D_3 are random diagonal matrices.

If we spend the entire budget of attempts, it is likely that the result returned is either the all-zeros vector.

Included below are performance statistics for various configurations of SwendsenWang. Each configuration completed 100 iterations on Pangolin, a Dell Precision 5280 workstation with an 18-core@1.305GhZ Intel Xeon W-2295 CPU. Warns indicates the number of NumericalInstabilityWarnings caught during the run, and Zeros the number of all-zeros vectors returned.



Samples in \mathbb T^2_N

s/iteration Warns Zeros
Field $N$ maxTries
$\mathbb Z/2\mathbb Z$ 724 8 0.483 0 2
16 0.886 0 1
32 1.701 0 0
1024 8 1.190 0 3
16 1.969 0 1
32 3.351 0 0
1448 8 2.432 0 3
16 4.349 0 2
32 5.320 0 0
$\mathbb Z/3\mathbb Z$ 724 8 0.306 0 0
16 0.376 0 0
32 0.372 0 0
1024 8 0.751 0 0
16 0.915 0 0
32 0.919 0 0
1448 8 1.376 0 0
16 1.657 0 0
32 2.898 0 0
$\mathbb Z/5\mathbb Z$ 724 8 0.246 0 0
16 0.249 0 0
32 0.237 0 0
1024 8 0.551 0 0
16 0.567 0 0
32 0.506 0 0
1448 8 1.332 0 0
16 1.357 0 0
32 1.537 0 0
$\mathbb Z/7\mathbb Z$ 724 8 0.254 0 0
16 0.245 0 0
32 0.246 0 0
1024 8 0.529 0 0
16 0.537 0 0
32 0.526 0 0
1448 8 1.319 0 0
16 1.301 0 0
32 1.395 0 0



Samples in \mathbb T^4_N

s/iteration Warns Zeros
Field $N$ maxTries
$\mathbb Z/2\mathbb Z$ 20 8 0.176 11 11
16 0.187 6 6
32 0.189 3 3
30 8 1.085 12 12
16 1.103 6 6
32 1.160 3 3
40 8 4.264 12 12
16 4.360 5 5
32 4.499 2 2
$\mathbb Z/3\mathbb Z$ 20 8 0.126 12 0
16 0.179 6 6
32 0.125 2 0
30 8 1.088 12 12
16 1.053 4 4
32 1.051 2 2
40 8 3.934 11 11
16 3.815 4 4
32 3.961 2 2
$\mathbb Z/5\mathbb Z$ 20 8 0.128 9 0
16 0.134 6 0
32 0.136 3 0
30 8 0.939 12 0
16 0.867 6 0
32 0.913 2 0
40 8 3.506 11 0
16 3.654 6 0
32 3.331 2 0
$\mathbb Z/7\mathbb Z$ 20 8 0.149 12 0
16 0.143 5 0
32 0.135 2 0
30 8 0.984 12 0
16 0.833 4 0
32 0.969 2 0
40 8 3.855 12 0
16 3.552 5 0
32 3.579 2 0
Expand source code
class SwendsenWang():
        _name = "SwendsenWang"

        def __init__(
                        self, C, dimension=1, field=2, temperature=constant(-0.6), initial=None, LinBox=True,
                        parallel=False, minBlockSize=32, maxBlockSize=64, cores=4,
                        maxTries=16
                ):
                r"""
                Initializes Swendsen-Wang evolution on the Potts model.

                Args:
                        C (Complex): The `Complex` object on which we'll be running experiments.
                        dimension (int=1): The dimension of cells on which we're percolating.
                        field (int=2): Field characteristic.
                        temperature (Callable): A temperature schedule function which
                                takes a single positive integer argument `t`, and returns the
                                scheduled temperature at time `t`.
                        initial (np.array): A vector of spin assignments to components.
                        LinBox (bool=True): Uses fast LinBox routines instead of slow inbuilt
                                ones. WARNING: using inbuilt methods may dramatically increase
                                computation time.
                        parallel (bool=False): Should matrix computations be done in parallel? (Uses C/C++).
                        minBlockSize (int=32): If `parallel` is truthy, this is the smallest
                                number of columns processed in parallel.
                        maxBlockSize (int=64): If `parallel` is truthy, this is the largest
                                number of columns processed in parallel.
                        cores (int=4): Number of available CPUs/cores/threads on the machine.
                        maxTries (int=16): The number of attempts LinBox makes to sample a nonzero
                                vector in the kernel of the coboundary matrix.


                If the first vector sampled
                by the Lanczos algorithm is all zeros, we perform the following
                steps until a nonzero vector is found, or we exhaust the number
                of attempts:

                1. sample once from \(A\) with no preconditioner;
                2. sample up to twice from \(D_0A\), where \(D_0\) is a random diagonal matrix;
                3. sample up to twice from \(A^\top D_1 A\), where \(D_1\) is a random diagonal matrix;
                4. sample for the remainder of the attempts from \(D_2 A^\top D_3 A D_2\), where \(D_2, D_3\) are random diagonal matrices.
                        
                If we spend the entire budget of attempts, it is likely that the
                result returned is either the all-zeros vector.
                
                Included below are performance statistics for various configurations of
                `SwendsenWang`. Each configuration completed 100 iterations on Pangolin,
                a Dell Precision 5280 workstation with an 18-core@1.305GhZ Intel Xeon W-2295
                CPU. **Warns** indicates the number of `NumericalInstabilityWarning`s caught
                during the run, and **Zeros** the number of all-zeros vectors returned.

                </br>
                </br>
                <center> <strong> Samples in \(\mathbb T^2_N\) </strong> </center>
                .. include:: ./tables/SwendsenWang.LanczosKernelSample.2.html

                </br>
                </br>
                <center> <strong> Samples in \(\mathbb T^4_N\) </strong> </center>
                .. include:: ./tables/SwendsenWang.LanczosKernelSample.4.html
                """
                # Object access.
                self.complex = C
                self.temperature = temperature
                self.dimension = dimension
                self._returns = 2
                self.field = field

                # Force-recompute the matrices for a different dimension; creates
                # a set of orientations for fast elementwise products.
                self.matrices = Matrices()
                self.matrices.full = self.complex.matrices.full

                boundary, coboundary = self.complex.recomputeBoundaryMatrices(dimension)
                self.matrices.boundary = boundary
                self.matrices.coboundary = coboundary

                # Useful values to have later.
                self.cells = len(self.complex.Boundary[self.dimension])
                self.faces = len(self.complex.Boundary[self.dimension-1])

                # Check the dimensions of the boundary/coboundary matrices by comparing
                # the number of cells. LinBox is really sensitive to smaller-size matrices,
                # but can easily handle large ones.
                if self.cells*self.faces < 10000 and LinBox:
                        warnings.warn(f"complex with {self.cells*self.faces} boundary matrix entries is too small for accurate matrix solves; may segfault.", TooSmallWarning, stacklevel=2)

                # Seed the random number generator.
                self.RNG = np.random.default_rng()

                # If no initial spin configuration is passed, initialize.
                if not initial: self.spins = self._initial()
                else: self.spins = (initial%self.field).astype(FINT)

                # Delegate computation.
                self._delegateComputation(LinBox, parallel, minBlockSize, maxBlockSize, cores, maxTries)

        
        def _delegateComputation(self, LinBox, parallel, minBlockSize, maxBlockSize, cores, maxTries):
                # If we use LinBox, keep everything as-is.
                if LinBox:
                        def sample(zeros):
                                # Not currently sure how to handle this... maybe we'll just "reject"
                                # for now, come back, and sub something else in later. We shouldn't
                                # be halting computation. For now, we should raise an exception that
                                # the Chain catches, and warns the user by exiting with exit code
                                # 1 or 2.
                                try:
                                        return np.array(LanczosKernelSample(
                                                self.matrices.coboundary, zeros, 2*self.dimension,
                                                self.faces, self.field, maxTries=maxTries
                                        ), dtype=FINT)
                                except Exception as e:
                                        raise NumericalInstabilityWarning(e)

                
                # If we use inbuilt routines, we need to actually construct the matrix
                # form of the coboundary matrix, which is extremely sparse (and really big).
                # Makes things slow.
                else:
                        coboundary = np.zeros((self.cells, self.faces), dtype=FINT)
                        rows = self.matrices.coboundary[::3]
                        cols = self.matrices.coboundary[1::3]
                        entries = (self.matrices.coboundary[2::3]%self.field).astype(FINT)

                        coboundary[rows,cols] = entries
                        Reducer = MatrixReduction(self.field, parallel, minBlockSize, maxBlockSize, cores)

                        def sample(zeros):
                                return KernelSample(Reducer, coboundary.take(zeros, axis=0)).astype(FINT)
                        
                self.sample = sample



        def _initial(self):
                """
                Computes an initial state for the model's Complex.

                Returns:
                        A numpy `np.array` representing a vector of spin assignments.
                """
                return self.RNG.integers(
                        0, high=self.field, dtype=FINT, size=self.faces
                )
        

        def _proposal(self, time):
                """
                Proposal scheme for generalized Swendsen-Wang evolution on the Potts model.

                Args:
                        time (int): Step in the chain.

                Returns:
                        A numpy array representing a vector of spin assignments.
                """
                # Compute the probability of choosing any individual cube in the complex.
                T = self.temperature(time)
                p = 1-np.exp(T)
                assert 0 <= p <= 1

                # Choose cubes to include.
                uniform = self.RNG.uniform(size=self.cells)
                include = np.nonzero(uniform < p)[0]
                boundary = self.complex.Boundary[self.dimension]
                q = self.field

                # Evaluate the current spin assignment (cochain).
                coefficients = self.spins[boundary[include]]
                coefficients[:,1::2] = -coefficients[:,1::2]%q
                sums = coefficients.sum(axis=1)%q
                zeros = np.nonzero(sums == 0)[0]

                # Create output vectors.
                satisfied = np.zeros(self.cells, dtype=FINT)
                satisfied[zeros] = 1

                # Sample from the kernel of the coboundary matrix.
                spins = self.sample(zeros)

                return spins, satisfied
        

        def _assign(self, cocycle):
                """
                Updates mappings from faces to spins and cubes to occupations.

                Args:
                        cocycle (np.array): Cocycle on the subcomplex.
                
                Returns:
                        None.
                """
                self.spins = cocycle