Skip to content

Voter variants

Voter model variants on a network, with threshold voters and a share of contrarians.

  • Name: voter_variants
  • Field: social/opinion_dynamics
  • Formalism: agent-based model
  • References: Thurner, Hanel, and Klimek 2018; Galam 2004

Factory

@register_model(
    field='social/opinion_dynamics',
    tags=('opinion', 'agents', 'network', 'voting', 'contrarians'),
    references=('Thurner, Hanel, and Klimek 2018', 'Galam 2004'),
    defaults={'until': 100},
)
def voter_variants(
    graph: object = None,
    *,
    threshold: float = 0.5,
    contrarians: float = 0.1,
    share: float = 0.5,
    random_state: int | np.random.Generator | None = 0,
) -> System:
    """Return voter model variants on a network, with threshold voters and a share of contrarians.

    Args:
        graph:
            The NetworkX graph, or `None` for a ring of 50 nodes each linked to its four nearest neighbours.

        threshold:
            The share of disagreeing neighbours above which a voter changes its opinion, 0.5 for the majority rule, or
            0 to copy a random neighbour.

        contrarians:
            The share of voters that are contrarians.

        share:
            The share of voters with opinion 1.

        random_state:
            The seed or generator of the opinions and of the contrarians.

    Returns:
        system:
            The model, with the group `voters`.

    Examples:
        >>> import networkx as nx
        >>> from skcomplex.models.social.opinion_dynamics import voter_variants
        >>> voter_variants(nx.cycle_graph(8), random_state=0).elements[0].check().prepare().size_
        8
    """
    rng = np.random.default_rng(random_state)
    graph = nx.watts_strogatz_graph(50, 4, 0.0) if graph is None else graph
    size = nx.number_of_nodes(graph)
    voters = GroupElement(
        'voters',
        element=VariantVoterElement,
        size=size,
        values={
            'opinion': (rng.random(size) < share).astype(float),
            'contrarian': (rng.random(size) < contrarians).astype(float),
            'threshold': threshold,
        },
    )
    return System(
        elements=[voters],
        interactions=[VariantVotingInteraction('voting', target='voters')],
        space=NetworkSpace(graph),
    )

Components

class VariantVoterElement(Element):
    """Agent of the voter model variants: an imitator, a threshold voter, or a contrarian.

    With a threshold of 0 the voting copies a random neighbour into the agent, as in the voter model. With a positive
    threshold it gives the agent the opinion opposite to its own when the share of neighbours that hold it exceeds the
    threshold, so a threshold of 0.5 gives the majority rule. A contrarian adopts the opposite of what it would
    otherwise adopt.

    Args:
        label:
            The label of the agent or group.

        visual:
            How the agent is drawn, or `None` for `default_visual_`.

        opinion:
            The initial opinion, 0 or 1.

        contrarian:
            Whether the agent is a contrarian, 1, or not, 0.

        threshold:
            The share of disagreeing neighbours above which the agent changes its opinion, or 0 to copy a random
            neighbour.

    Examples:
        >>> from skcomplex.core.elements import GroupElement
        >>> from skcomplex.models.social.opinion_dynamics import VariantVoterElement
        >>> group = GroupElement('voters', element=VariantVoterElement, size=10, values={'threshold': 0.5})
        >>> group.label, group.element.__name__, group.size
        ('voters', 'VariantVoterElement', 10)
    """

    def __init__(
        self,
        label: str,
        visual: VisualSpec | None = VisualSpec('agents', {'color_by': 'opinion', 'color_range': (0.0, 1.0)}),
        *,
        opinion: Annotated[float, StateSpec(domain=NN)] = 0.0,
        contrarian: Annotated[float, StateSpec(domain=FiniteEnumeratedSet([0, 1]))] = 0.0,
        threshold: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 0.0,
    ) -> None:
        super().__init__(label, visual=visual)
        self.opinion = opinion
        self.contrarian = contrarian
        self.threshold = threshold

    @classmethod
    def _get_view(cls) -> type[VariantVoterView]:
        """Return the class of the views of the agents, whose attributes are the states and parameters."""
        return VariantVoterView

class VariantVotingInteraction(MapInteraction):
    """Voting of the voter model variants, where every voter in turn takes the opinion of its variant.

    A voter of threshold 0 copies a random neighbour. A voter of positive threshold adopts the opinion opposite to its
    own when the share of neighbours that hold it exceeds the threshold. A contrarian adopts the opposite of what it
    would otherwise adopt. The voters update one after the other in a new random order every step, each reading the
    opinions the voters before it set, and a voter without a neighbouring voter keeps its opinion.

    Args:
        label:
            The label of the interaction.

        target:
            The path of the group of voters.

    Examples:
        >>> from skcomplex.models.social.opinion_dynamics import VariantVotingInteraction
        >>> VariantVotingInteraction('voting', target='voters').check().schedule
        'random-sequential'
    """

    def __init__(self, label: str, *, target: Annotated[str, RoleSpec()]) -> None:
        super().__init__(
            label,
            target=target,
            state='opinion',
            function=self._get_opinion,
            schedule='random-sequential',
            form='agent',
        )

    def _get_choice(
        self,
        agent: VariantVoterView,
        neighbors: list[VariantVoterView],
        model: ModelViewProtocol,
    ) -> float:
        """Return the opinion a voter that conforms would take."""
        if agent.threshold <= 0:
            return float(neighbors[int(model.rng.integers(len(neighbors)))].opinion)
        disagreeing = sum(neighbor.opinion != agent.opinion for neighbor in neighbors) / len(neighbors)
        return float(1 - agent.opinion) if disagreeing > agent.threshold else float(agent.opinion)

    def _get_opinion(self, agent: VariantVoterView, model: ModelViewProtocol) -> float:
        """Return the opinion of the voter's variant, inverted for a contrarian."""
        neighbors = [item for item in agent.neighbors() if isinstance(item, VariantVoterView)]
        if not neighbors:
            return float(agent.opinion)
        choice = self._get_choice(agent, neighbors, model)
        return 1 - choice if agent.contrarian else choice