Skip to content

Membership

A toy open-population model of scheme membership - members join and churn (leave) over time - used to showcase neworder.SplitMix64, a hash-based random stream that is an alternative to the default sequential MonteCarlo engine. It is aimed at open population models, where individuals join and leave over time, and per-individual reproducibility needs to survive changes in population size, membership or row order.

Examples Source Code

The code for all the examples can be obtained by either:

  • downloading and extracting the examples archive from the neworder releases page, or
  • pulling the docker image virgesmith/neworder:latest (more here

Why SplitMix64?

MonteCarlo is a sequential stream: every draw advances its internal state, so the value returned depends on how many draws were taken before it. That's fine for a fixed, ordered population, but it means the draw a given individual receives is really a property of when it was requested, not of the individual itself. If the population changes shape between runs - someone leaves, someone new is inserted earlier in the DataFrame, the rows get reordered - the same individual can end up with a different draw for no modelling reason.

SplitMix64 has no state to advance. Each variate is computed by hashing a set of integer keys - typically a person id, a process id and a timestep - together with a seed. The draw for a given key is always the same, regardless of which other keys are present or in what order they're processed. See Tips and Tricks for the full API reference.

The model

A toy open-population scheme: it starts with a fixed number of members, and each year:

  • every existing member churns (leaves) with a fixed annual hazard,
  • some number of new members join.
./examples/membership/run.py
"""
Membership

The model is a toy scheme membership: members join each year and
churn (leave) at a fixed annual hazard. finalise() then proves, empirically,
that SplitMix64 draws are stable under sub-sampling and reordering, and
contrasts this with the sequential MonteCarlo stream, which is not.
"""

from membership import Membership  # ty:ignore[unresolved-import]

import neworder as no

# Initial member population
N0 = 1000
p_churn = 0.1  # probability of leaving in a given year
p_join = 0.12  # probability of joining in a given year

timeline = no.LinearTimeline(2026, 2036, 10)
model = Membership(timeline, n0=N0, p_churn=p_churn, p_join=p_join)
ok = no.run(model)
if not ok:
    no.log("model failed!")

Implementation

The model constructs a SplitMix64 instance alongside the usual MonteCarlo engine, and uses a constant master seed - the "churn" stream via SplitMix64.hash64 - ensuring that calls to uarray with identical arguments will produce identical results.:

./examples/membership/membership.py
class Membership(no.Model):
    """
    An open population of scheme members. Each year some members churn
    (leave) and new members join. Demonstrates neworder.SplitMix64:
    - construct
    - step
    - finalise
    """

    def __init__(self, timeline: no.Timeline, n0: int, p_churn: float, p_join: float) -> None:
        super().__init__(timeline, no.MonteCarlo.deterministic_identical_stream)

        # Stateless (use_counter=False, the default): calling uarray() twice
        # with the same arguments always returns the same values. That's
        # exactly what's needed here - the churn draw for member i in year y
        # must be fixed, however many other members join or leave around it.

        # a stable master seed for this stochastic process - will be the same for each
        # draw from uarray(...) - identical arguments to this function will produce
        # identical results
        CHURN = no.SplitMix64.hash64("churn")
        self.rng = no.SplitMix64(lambda: CHURN)

        self.p_churn = p_churn
        self.avg_joiners = round(n0 * p_join)

        self.members = pd.DataFrame(index=no.df.unique_index(n0), data={"joined": timeline.start})
        self.members.index.name = "id"

        self.history: list[tuple[float, int]] = [(timeline.start, len(self.members))]

Each year, step draws one churn variate per member id and year - not per row - so the outcome for a given member cannot be affected by who else is in the population that year, and the outcomes for different years are independent:

./examples/membership/membership.py
    def step(self) -> None:
        year = self.timeline.time

        # One SplitMix64 draw per member *id*, keyed on the process and the
        # year - not on a member's position in self.members, or on how many
        # other members are currently present.
        ids = self.members.index.to_numpy()
        u = self.rng.uarray(ids, int(year))
        self.members = self.members.loc[u >= self.p_churn]

        # New members joining are genuinely new events with no prior identity
        # to preserve, so an ordinary sequential draw is fine here.
        n_new = int(self.mc.ustream(1)[0] * 2 * self.avg_joiners)
        new_ids = no.df.unique_index(n_new)
        self.members = pd.concat([self.members, pd.DataFrame(index=new_ids, data={"joined": year})])
        self.members.index.name = "id"

        self.history.append((year + self.timeline.dt, len(self.members)))

Proving the invariance

finalise doesn't just report the membership history - it demonstrates the property the example is built around, using the final surviving population:

./examples/membership/membership.py
    def prove_invariance(self) -> None:
        """
        The property that makes SplitMix64 suited to open populations: a
        member's draw depends only on its id, the process and the year -
        never on population size, membership or row order.
        """
        ids = self.members.index.to_numpy()
        year = int(self.timeline.time)

        # 1. sub-population invariance: drawing for the whole population and
        #    drawing for a single member in isolation agree for that member.
        whole = self.rng.uarray(ids, year)
        lone = self.rng.uarray(ids[:1], year)
        assert whole[0] == lone[0]

        # 2. reordering invariance: shuffling the ids changes their position
        #    in the output array, but not the value attached to any id.
        order = np.random.default_rng(0).permutation(len(ids))
        shuffled = self.rng.uarray(ids[order], year)
        np.testing.assert_array_equal(shuffled[np.argsort(order)], whole)

        no.log("SplitMix64: a member's draw is unchanged by sub-sampling or reordering")

        # Contrast with the sequential MonteCarlo stream: the value at
        # position i depends on when it was drawn, not on which id occupies
        # that position, so the same id can get a different value purely
        # because of where it sits in the DataFrame.
        self.mc.reset()
        original = dict(zip(ids.tolist(), self.mc.ustream(len(ids)).tolist(), strict=True))
        self.mc.reset()
        reordered = dict(zip(ids[order].tolist(), self.mc.ustream(len(ids)).tolist(), strict=True))
        changed = sum(original[i] != reordered[i] for i in ids)
        no.log(f"MonteCarlo: reordering alone changed the draw for {changed}/{len(ids)} members")

Two checks against SplitMix64:

  1. Sub-population invariance - hashing the whole population and hashing a single member in isolation give the same answer for that member. Nothing else in the population affects it.
  2. Reordering invariance - shuffling the order of the member ids changes their position in the output array, but every id still maps back to the same value.

Both are asserted directly in the code, so the example fails loudly if either property is ever broken.

For contrast, the same reordering is then applied to a plain MonteCarlo.ustream() draw, resetting the engine (via self.mc.reset()) between the two calls so both start from the same seed. Because ustream output is positional, every single member ends up with a different value purely because of where it sits in the shuffled array.

Output

python examples/membership/run.py
[py 0/1(2956824)] 2026: 1000 members
[py 0/1(2956824)] 2027: 995 members
[py 0/1(2956824)] 2028: 892 members
...
[py 0/1(2956824)] 2036: 984 members
[py 0/1(2956824)] SplitMix64: a member's draw is unchanged by sub-sampling or reordering
[py 0/1(2956824)] MonteCarlo: reordering alone changed the draw for 984/984 members

The exact membership counts will vary between runs of this example, but the last two lines will always read the same way: SplitMix64 is unaffected by the shuffle, and MonteCarlo changes for (close to) every member.

Next steps

Compare this against the Mortality or People examples, which use the sequential MonteCarlo stream for a fixed, closed population where positional draws are not a concern. See Tips and Tricks for the complete SplitMix64 API, including use_counter for getting independent repeated draws.