Skip to content

Social impact theory

The social impact model on a full grid, with random persuasiveness and supportiveness.

  • Name: social_impact
  • Field: social/opinion_dynamics
  • Formalism: agent-based model
  • References: Nowak, Szamrej, and Latané 1990; Castellano, Fortunato, and Loreto 2009

Factory

@register_model(
    field='social/opinion_dynamics',
    tags=('opinion', 'agents', 'grid', 'clusters'),
    references=('Nowak, Szamrej, and Latané 1990', 'Castellano, Fortunato, and Loreto 2009'),
    defaults={'until': 20},
)
def social_impact(
    width: int = 15,
    height: int = 15,
    *,
    share: float = 0.5,
    exponent: float = 2.0,
    noise: float = 0.0,
    random_state: int | np.random.Generator | None = 0,
) -> System:
    """Return the social impact model on a full grid, with random persuasiveness and supportiveness.

    Args:
        width:
            The number of columns.

        height:
            The number of rows.

        share:
            The share of agents with opinion 1, the others holding -1.

        exponent:
            The exponent of the distance by which impact decays.

        noise:
            The half-width of the uniform random field.

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

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

    Examples:
        >>> from skcomplex.models.social.opinion_dynamics import social_impact
        >>> social_impact(4, 5, random_state=0).elements[0].check().prepare().size_
        20
    """
    rng = np.random.default_rng(random_state)
    size = width * height
    people = GroupElement(
        'people',
        element=InfluenceeElement,
        size=size,
        values={
            'opinion': 2 * _shares(size, share, rng) - 1,
            'persuasiveness': rng.random(size),
            'supportiveness': rng.random(size),
            'exponent': exponent,
            'noise': noise,
        },
        location=[(column, row) for column in range(width) for row in range(height)],
    )
    return System(
        elements=[people],
        interactions=[SocialImpactInteraction('impact', target='people')],
        space=LatticeSpace((width, height), boundary='fixed', capacity=1),
    )

Components

class InfluenceeElement(Element):
    """Agent of the social impact model, with an opinion of -1 or 1, a persuasiveness, and a supportiveness.

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

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

        opinion:
            The initial opinion, -1 or 1.

        persuasiveness:
            The strength with which the agent persuades agents of the other opinion.

        supportiveness:
            The strength with which the agent supports agents of its opinion.

        exponent:
            The exponent of the distance by which impact decays.

        noise:
            The half-width of the uniform random field added to the impact on every agent and step.

    Examples:
        >>> from skcomplex.core.elements import GroupElement
        >>> from skcomplex.models.social.opinion_dynamics import InfluenceeElement
        >>> group = GroupElement('people', element=InfluenceeElement, size=4, values={'opinion': -1.0})
        >>> group.label, group.element.__name__, group.size
        ('people', 'InfluenceeElement', 4)
    """

    def __init__(
        self,
        label: str,
        visual: VisualSpec | None = VisualSpec('agents', {'color_by': 'opinion', 'color_range': (-1.0, 1.0)}),
        *,
        opinion: Annotated[float, StateSpec(domain=ZZ)] = 1.0,
        persuasiveness: Annotated[float, StateSpec(domain=RealSet.closed_open(0, oo))] = 1.0,
        supportiveness: Annotated[float, StateSpec(domain=RealSet.closed_open(0, oo))] = 1.0,
        exponent: Annotated[float, ParameterSpec(domain=RealSet.open(0, oo))] = 2.0,
        noise: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 0.0,
    ) -> None:
        super().__init__(label, visual=visual)
        self.opinion = opinion
        self.persuasiveness = persuasiveness
        self.supportiveness = supportiveness
        self.exponent = exponent
        self.noise = noise

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

class SocialImpactInteraction(MapInteraction):
    """Synchronous update of the social impact model, where every agent takes the opinion of the stronger impact.

    The impact on agent `i` is `I_i = sum_j t_j (1 - o_i o_j) / d_ij^a - sum_j s_j (1 + o_i o_j) / d_ij^a`, with the
    persuasiveness `t`, the supportiveness `s`, and the distance `d` on the grid, where an agent supports itself from
    distance 1. The impact is the net pressure to change, whatever the opinion, so an agent flips its opinion when
    `I_i + h_i > 0`, with a uniform random field `h_i`. Every agent decides from the opinions at the start of the step.

    Args:
        label:
            The label of the interaction.

        target:
            The path of the group of agents.

    Examples:
        >>> from skcomplex.models.social.opinion_dynamics import SocialImpactInteraction
        >>> SocialImpactInteraction('impact', target='people').check().form
        'agent'
    """

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

    def _get_impact(self, agent: InfluenceeView, model: ModelViewProtocol) -> float:
        """Return the net pressure to change on an agent from every agent of the group, itself from distance 1."""
        agents = [item for item in model.agents('target') if isinstance(item, InfluenceeView)]
        locations = np.array([item.location for item in agents], dtype=float)
        distances = np.linalg.norm(locations - np.asarray(agent.location, dtype=float), axis=-1)
        distances[[item.id == agent.id for item in agents]] = 1.0
        weights = distances**-agent.exponent
        agreement = agent.opinion * np.array([item.opinion for item in agents], dtype=float)
        persuasion = np.array([item.persuasiveness for item in agents], dtype=float)
        support = np.array([item.supportiveness for item in agents], dtype=float)
        return float((weights * persuasion * (1 - agreement)).sum() - (weights * support * (1 + agreement)).sum())

    def _get_opinion(self, agent: InfluenceeView, model: ModelViewProtocol) -> float:
        """Return the opposite opinion of an agent under a positive net pressure to change, and its own otherwise."""
        field = model.rng.uniform(-agent.noise, agent.noise)
        return -agent.opinion if self._get_impact(agent, model) + field > 0 else float(agent.opinion)