Back to Article
Supplementary 2: Choice model comparison
Download Notebook

Supplementary 2: Choice model comparison

Preference model comparison

To investigate the relationship between task structures and participants preferences, we fitted a model in which we fitted a latent preference score computed as the weighted sum of regressors associated with each factors of our task (offers, energy, current and future costs) where the weight associated with each are estimated from participants data. In addition, the model comprised a fixed effect for the decision values derived from reinforcement learning (RL) backward induction method to represent the behaviour of an optimal planner, as well as an interaction between the decision values and the entropy of the preferences scores, to investigate whether participants reliance on planning depends on the strength of their preferences.

This model was observed to fit the data best, showing that participants behaviour reflects the integration of a planning component together with priors reflecting the structure of the task, shedding light on the structure of the priors themselves. To further show that participants priors reflect the whole structure of the task, we modelled the data removing one experimental factor from the preferences fitting one at a time and compared to the full model, to show that our results cannot be explained by the fact that participants’s priors do not reflect a single nor a subset of experimental factors.

In [1]:
# Import all packages

# General utilities:
import os 
import pandas as pd
import numpy as np
import matplotlib
import matplotlib.pyplot as plt
import warnings
from pathlib import Path
from IPython.display import Markdown, display

# Stats
import pymc as pm
import arviz as az
import bambi as bmb
from scipy.special import logit

# Custom packages:
from stabst.MarkovDecisionProcess import MDP
from stabst.TaskConfig import LimitedEnergyTask
from stabst.utils import avg_reduce_mdp, abstract2ground_value

# Set random seed for reproducibility:
np.random.seed(42)
warnings.filterwarnings("ignore")

n_samples = 1000
n_warmup = 1000
n_chains = 4

control_models_path = "./data/bids/limited_energy/derivatives/control_models/"
if not os.path.exists(control_models_path):
    os.makedirs(control_models_path)

# Choice behaviour model with preferences and decision values:
def preference_model(
        y: np.array,
        decision_values: np.array,
        pref_regressors: pd.DataFrame,
        subject_index: np.array,
        subject_labels: np.array      
):
    '''
    Parameters
    ----------
    y : np.array [N samples, ]
        Observed binary data (1, 0...)
    decision_values : np.array [N samples, ]
        Decision values to regress onto the observed data
    pref_regressors : np.array [N samples, M regressors]
        Regressor to fit participants preference for. We can have M regressors
    subject_index : np.array  [N samples, ]
        Index of the subject associated with each observation
    subject_labels : np.array  [N subjects, ]
        Single identifier of each subject
    coords : dict  
        "subject": subj_labels, 
        "coef": ["intercept", "slope"],
        The subject maps the data to each subject, the coef are for the coefficients
    b_prior_mean : Optional[float], optional
        Prior mean of each beta parameters, by default 0
    b_prior_sigma : Optional[float], optional
        Prior variance of the population level distribution of the beta, by default 2
    s_prior_sigma : Optional[float], optional
        Prior between subjects variance, by default 2
    n_drawss : Optional[int], optional
        Number of draws for the posterior, by default 1000
    n_tuning_draws : Optional[int], optional
        Number of tuning draws, by default 1000
    Returns
    -------
    tuple[pm.Model, arviz.InferenceData]
        pm.model : pymc model object
        idata : arviz inference data
    """
    '''
    # Get dimensions:
    n_obs = y.shape[0]
    n_groups = subject_labels.shape[0]
    n_pref = pref_regressors.shape[1]

    # Create intercept:
    intercept = np.ones(n_obs)

    # Set coordinates:
    coords = {
        "subject": subject_labels,
        "coef_intercept": ["B_intercept"],
        "coef_planning": ["B_plan"],
        "coef_pref": ["B_" + col for col in pref_regressors.columns],
        "coef_interaction": ["slope"],
    }


    # Model:
    with pm.Model(coords=coords) as planning_preferences_interaction_model:
        # Data:
        y_obs = pm.Data("y_obs", y)
        intercept = pm.Data("intercept", intercept)
        planning = pm.Data("planning", decision_values)
        preferences = pm.Data("preferences", pref_regressors)
        subj_idx = pm.Data("subj_idx", subject_index.astype("int32"))

        # Hyperpriors:
        # Intercept term
        beta_intercept = pm.Normal("beta_intercept", mu=0, sigma=2, dims="coef_intercept")
        sigma_intercept = pm.HalfNormal("sigma_intercept", sigma=2, dims="coef_intercept")
        # Planning term:
        beta_planning = pm.Normal("beta_planning", mu=0, sigma=2, dims="coef_planning")
        sigma_planning = pm.HalfNormal("sigma_planning", sigma=2, dims="coef_planning")
        # Preference terms:
        beta_pref = pm.Normal("beta_pref", mu=0, sigma=2, dims="coef_pref")
        sigma_pref = pm.HalfNormal("sigma_pref", sigma=2, dims="coef_pref")
        # Interaction term:
        beta_interaction = pm.Normal("beta_interaction", mu=0, sigma=2, dims="coef_interaction")
        sigma_interaction = pm.HalfNormal("sigma_interaction", sigma=2, dims="coef_interaction")

        # Offset parameters:
        z_intercept = pm.Normal("z_intercept", 0, 1, dims=("subject", "coef_intercept"))
        z_planning = pm.Normal("z_planning", 0, 1, dims=("subject", "coef_planning"))
        z_biases = pm.Normal("z_biases", 0, 1, dims=("subject", "coef_pref"))
        z_interaction = pm.Normal("z_interaction", 0, 1, dims=("subject", "coef_interaction"))

        # Centered parameters:
        beta_intercept_sub = pm.Deterministic("beta_intercept_sub", beta_intercept + z_intercept * sigma_intercept, 
                                              dims=("subject", "coef_intercept"))
        beta_planning_sub = pm.Deterministic("beta_planning_sub", beta_planning + z_planning * sigma_planning, 
                                             dims=("subject", "coef_planning"))
        beta_pref_sub = pm.Deterministic("beta_pref_sub", beta_pref + z_biases * sigma_pref, 
                                         dims=("subject", "coef_pref"))
        beta_interaction_sub = pm.Deterministic("beta_interaction_sub", beta_interaction + z_interaction * sigma_interaction, 
                                                dims=("subject", "coef_interaction"))
        
        # Estimate the score of the bias (i.e. weighted sum of each of the biases regressors):
        preference = pm.Deterministic('preference', (beta_pref_sub[subj_idx] * preferences).sum(axis=-1))
        
        # Convert the bias back onto probability space:
        pi_prior = pm.Deterministic("pi_prior", pm.math.sigmoid(preference))

        # Compute the entropy:
        entropy = pm.Deterministic("entropy", -pi_prior * pm.math.log(pi_prior) - (1-pi_prior) * pm.math.log(1 - pi_prior))
        
        # Eta parameter is the weighted sum of the intercept, the bias, the planning values and 
        # the interaction between the entropy of the bias and the planning
        eta = (
            beta_intercept_sub[subj_idx, 0] * intercept
            + preference
            + beta_planning_sub[subj_idx, 0] * planning
            + beta_interaction_sub[subj_idx, 0] * (entropy * planning)
        )
        
        # Expected values:
        p = pm.Deterministic("p", pm.math.sigmoid(eta))

        # Likelihood 
        pm.Bernoulli("y", p=p, observed=y_obs)

        # Sampling:
        idata = pm.sample(
            draws=n_samples,
            tune=n_warmup,
            chains=n_chains,
            target_accept=0.85,
            idata_kwargs={"log_likelihood": True},
        )

    return idata


def linear_decay_mvavg(arr, window_size, init_val = 0.5, kernel="linear"):
    '''
    Compute the moving average of a 1D array with a specified window size and 
    linear decay
    '''
    # Pad the array with initial value
    padded_arr = np.pad(arr.astype(float), (window_size - 1, 0), constant_values=init_val)
    # Generate kernel:
    if kernel.lower() == "linear":
        kern = np.arange(window_size)/np.sum(np.arange(window_size))
    elif kernel.lower() == "exponential":
        kern = np.exp(np.arange(window_size))/np.sum(np.exp(np.arange(window_size)))
    conv_result = np.convolve(padded_arr, kern, mode='valid')
    res = np.full(len(arr), init_val)
    # For t >= k, we can use conv_result[t-1]
    for t in range(window_size, len(arr)):
        res[t] = conv_result[t-1]
    return res

Preparing the data:

We apply the exact same preprocessing as in the manuscript notebook

In [2]:
# Download the data if needed:
if not os.path.exists('../data/raw_data/all_participants_data.csv'):
    if not os.path.exists('../data/raw_data'):
        os.makedirs('../data/raw_data')
    url = 'https://raw.githubusercontent.com/fmott/context_dependent_planning/4d239b721749adabb8fe8f1d8ac2d1ecdeba17cf/data/behaviour/data_all_participants_20220215120148.csv'
    os.system(f'wget {url} -O ../data/raw_data/all_participants_data.csv')
if not os.path.exists('../data/raw_data/all_participants_age_gender.csv'):
    if not os.path.exists('../data/raw_data'):
        os.makedirs('../data/raw_data')
    url = 'https://raw.githubusercontent.com/fmott/context_dependent_planning/4d239b721749adabb8fe8f1d8ac2d1ecdeba17cf/data/behaviour/age_gender.csv'
    os.system(f'wget {url} -O ../data/raw_data/all_participants_age_gender.csv')
# Load the data:
beh_data = pd.read_csv('../data/raw_data/all_participants_data.csv')
demographic_data = pd.read_csv('../data/raw_data/all_participants_age_gender.csv', sep=";")

# Extract demographic information:
n_participants = demographic_data.shape[0]
n_female = demographic_data['gender (m = 1, f = 2)'].value_counts()[2]
mean_age = demographic_data['age'].mean()
std_age = demographic_data['age'].std()

# ===================================================================
# Data preprocessing:
# Remove nans:
beh_data = beh_data.dropna()
# Remove timeout:
beh_data = beh_data[beh_data["timeout"] == 0]
# Flip responses: 1 = accept:
beh_data["response"] = (beh_data["response"] == 0).astype(int)
# Make trial 1 based
beh_data["trial"] = beh_data["trial"] + 1
# Generate future cost based on the transitions:
transitions_costs = {
    0: [1, 1],
    1: [2, 1],
    2: [1, 2],
    3: [2, 2]
}
beh_data["fc"] = [transitions_costs[row["transition"]][1] for _, row in beh_data.iterrows()]

# ===================================================================
# Prepare the decision values:
# Task MDP:
# Create the task and its parameters (transition probability, reward...):
task = LimitedEnergyTask(O=[1, 2, 3, 4], p_offer=[1/4] * 4)
task.build()

# Create full MDP and compute solution for later reference:
gamma = 1
task_mdp = MDP(task.states, task.tp, task.r, gamma, s2i=task.s2i)
V_full, Q_full = task_mdp.backward_induction()

# Add decision values to the data frame:
dv = Q_full[:, 1] - Q_full[:, 0]
# Loop through each trial to set DV:
dv_trials = []
for trial_i, trial in beh_data.iterrows():
    e, o, cc, t = trial.energy, trial.reward, trial.energy_cost, trial.trial
    fc = transitions_costs[trial.transition][1]
    dv_trials.append(dv[task.s2i[(e, o, cc, fc, t)]])
beh_data['dv'] = dv_trials
# Compute offer specific decision value regressors:
beh_data['dv_23'] = beh_data['dv'].to_numpy() * (beh_data['is_2'].to_numpy() + beh_data['is_3'].to_numpy())
beh_data['dv_14'] = beh_data['dv'].to_numpy() * (beh_data['is_1'].to_numpy() + beh_data['is_4'].to_numpy())

# ===================================================================
# Preparing regressors:
# Categorical offer regressor for high and low offer
beh_data['is_12'] = beh_data['is_1'].to_numpy() + beh_data['is_2'].to_numpy()
beh_data['is_34'] = beh_data['is_3'].to_numpy() + beh_data['is_4'].to_numpy()
# Continuous regressor for high and low offer:
beh_data['high_vs_low'] = beh_data['is_34'] - beh_data['is_12']

# Categorical costs regressor
beh_data['is_lc'] = (beh_data['energy_cost'] == 1).astype(int).to_numpy()
beh_data['is_hc'] = (beh_data['energy_cost'] == 2).astype(int).to_numpy()
# Categorical future costs regressor
beh_data['is_lfc'] = (beh_data['fc'] == 1).astype(int).to_numpy()
beh_data['is_hfc'] = (beh_data['fc'] == 2).astype(int).to_numpy()

# Categorical transition regressor
beh_data['is_trans1'] = (beh_data['transition'] == 0).to_numpy()
beh_data['is_trans2'] = (beh_data['transition'] == 1).to_numpy()
beh_data['is_trans3'] = (beh_data['transition'] == 2).to_numpy()
beh_data['is_trans4'] = (beh_data['transition'] == 3).to_numpy()

# Categorical energy regressor:
beh_data['e_is_0'] = (beh_data['energy'] == 0).to_numpy()
beh_data['e_is_1'] = (beh_data['energy'] == 1).to_numpy()
beh_data['e_is_2'] = (beh_data['energy'] == 2).to_numpy()
beh_data['e_is_3'] = (beh_data['energy'] == 3).to_numpy()
beh_data['e_is_4'] = (beh_data['energy'] == 4).to_numpy()
beh_data['e_is_5'] = (beh_data['energy'] == 5).to_numpy()
beh_data['e_is_6'] = (beh_data['energy'] == 6).to_numpy()

# Random effects
subj_idx_raw, subj_labels = pd.factorize(beh_data["vpn"])

Fitting the full model

First, we fit the full model, which will then be compared to reduced models:

In [3]:
# Fitting the models:
traces = {}
# ===================================================================
# Preference model:
if os.path.exists("./data/bids/limited_energy/derivatives/models/preferences_model_trace.nc"):
    idata = az.from_netcdf("./data/bids/limited_energy/derivatives/models/preferences_model_trace.nc")
    traces['full_preferences_model'] = idata
else:
    raise Exception("Make sure to run the index notebook first!")

Compute models removing each factors from the preferences:

Then, we compute the same model, but removing each factors from the task when computing the preferences:

In [4]:
# ===================================================================
# Model without offer related preferences:
if os.path.exists(Path(control_models_path, "preferences_model_no_offer_trace.nc")):
    idata = az.from_netcdf(Path(control_models_path, "preferences_model_no_offer_trace.nc"))
    traces['No_Offer'] = idata
else:
    preference_columns = [
        'is_lc', 'is_hc', 
        'is_lfc', 'is_hfc',
        'e_is_0', 'e_is_1', 'e_is_2', 'e_is_3', 'e_is_4', 'e_is_5', 'e_is_6'
    ]
    idata = preference_model(beh_data['response'],  # Subjects responses
                                np.squeeze(beh_data[['dv']].to_numpy()),  # Optimal decision values
                                beh_data[preference_columns],  # Preferences regressors
                                subj_idx_raw, subj_labels)
    # Add the idata to the rest:
    traces['No_Offer'] = idata
    # Save the trace to file:
    az.to_netcdf(traces['No_Offer'], Path(control_models_path, "preferences_model_no_offer_trace.nc"))

# ===================================================================
# Model without current costs related preferences:
if os.path.exists(Path(control_models_path, "preferences_model_no_costs_trace.nc")):
    idata = az.from_netcdf(Path(control_models_path, "preferences_model_no_costs_trace.nc"))
    traces['No_CC'] = idata
else:
    # Fit the model without the costs related preferences:
    preference_columns = [
        'is_1', 'is_2', 'is_3', 'is_4', 
        'is_lfc', 'is_hfc',
        'e_is_0', 'e_is_1', 'e_is_2', 'e_is_3', 'e_is_4', 'e_is_5', 'e_is_6'
    ]
    idata = preference_model(beh_data['response'],  # Subjects responses
                                np.squeeze(beh_data[['dv']].to_numpy()),  # Optimal decision values
                                beh_data[preference_columns],  # Preferences regressors
                                subj_idx_raw, subj_labels)
    # Add the idata to the rest:
    traces['No_CC'] = idata
    # Save the trace to file:
    az.to_netcdf(traces['No_CC'], Path(control_models_path, "preferences_model_no_costs_trace.nc"))

# ===================================================================
# Model without future costs related preferences:
if os.path.exists(Path(control_models_path, "preferences_model_no_future_costs_trace.nc")):
    idata = az.from_netcdf(Path(control_models_path, "preferences_model_no_future_costs_trace.nc"))
    traces['No_FC'] = idata
else:
    # Fit the model without the future costs related preferences:
    preference_columns = [
        'is_1', 'is_2', 'is_3', 'is_4', 
        'is_lc', 'is_hc', 
        'e_is_0', 'e_is_1', 'e_is_2', 'e_is_3', 'e_is_4', 'e_is_5', 'e_is_6'
    ]
    idata = preference_model(beh_data['response'],  # Subjects responses
                                np.squeeze(beh_data[['dv']].to_numpy()),  # Optimal decision values
                                beh_data[preference_columns],  # Preferences regressors
                                subj_idx_raw, subj_labels)
    # Add the idata to the rest:
    traces['No_FC'] = idata
    # Save the trace to file:
    az.to_netcdf(traces['No_FC'], Path(control_models_path, "preferences_model_no_future_costs_trace.nc"))

# ===================================================================
# Model without energy related preferences:
if os.path.exists(Path(control_models_path, "preferences_model_no_energy_trace.nc")):
    idata = az.from_netcdf(Path(control_models_path, "preferences_model_no_energy_trace.nc"))
    traces['No_Energy'] = idata
else:
    # Fit the model without the energy related preferences:
    preference_columns = [
        'is_1', 'is_2', 'is_3', 'is_4', 
        'is_lc', 'is_hc', 
        'is_trans1', 'is_trans2', 'is_trans3', 'is_trans4', 
    ]
    idata = preference_model(beh_data['response'],  # Subjects responses
                                np.squeeze(beh_data[['dv']].to_numpy()),  # Optimal decision values
                                beh_data[preference_columns],  # Preferences regressors
                                subj_idx_raw, subj_labels)
    # Add the idata to the rest:
    traces['No_Energy'] = idata
    # Save the trace to file:
    az.to_netcdf(traces['No_Energy'], Path(control_models_path, "preferences_model_no_energy_trace.nc"))

Model without decision values

In addition, we compared the full preference model with a model without decision values. This is to investigate whether participants behaviour might not rely on any sort of optimal planning but on simpler heuristic based on the structure of the task:

In [5]:
if os.path.exists(Path(control_models_path, "no_planning_model.nc")):
    idata = az.from_netcdf(Path(control_models_path, "no_planning_model.nc"))
    traces['No_Planning'] = idata
else:
    # Choice behaviour model with prefences only, no decision values:
    pref_regressors = beh_data[[
        'is_1', 'is_2', 'is_3', 'is_4', 
        'is_lc', 'is_hc', 
        'is_lfc', 'is_hfc',
        'e_is_0', 'e_is_1', 'e_is_2', 'e_is_3', 'e_is_4', 'e_is_5', 'e_is_6'
    ]]

    # Create intercept:
    intercept = np.ones(beh_data.shape[0])

    # Set coordinates:
    coords = {
        "subject": subj_labels,
        "coef_intercept": ["B_intercept"],
        "coef_planning": ["B_plan"],
        "coef_pref": ["B_" + col for col in pref_regressors.columns],
        "coef_interaction": ["slope"],
    }

    # Model:
    with pm.Model(coords=coords) as preference_model:
        # Data:
        y_obs = pm.Data("y_obs", beh_data['response'])
        intercept = pm.Data("intercept", intercept)
        preferences = pm.Data("preferences", pref_regressors)
        subj_idx = pm.Data("subj_idx", subj_idx_raw.astype("int32"))

        # Hyperpriors:
        # Intercept term
        beta_intercept = pm.Normal("beta_intercept", mu=0, sigma=2, dims="coef_intercept")
        sigma_intercept = pm.HalfNormal("sigma_intercept", sigma=2, dims="coef_intercept")
        # Preference terms:
        beta_pref = pm.Normal("beta_pref", mu=0, sigma=2, dims="coef_pref")
        sigma_pref = pm.HalfNormal("sigma_pref", sigma=2, dims="coef_pref")

        # Offset parameters:
        z_intercept = pm.Normal("z_intercept", 0, 1, dims=("subject", "coef_intercept"))
        z_biases = pm.Normal("z_biases", 0, 1, dims=("subject", "coef_pref"))

        # Centered parameters:
        beta_intercept_sub = pm.Deterministic("beta_intercept_sub", beta_intercept + z_intercept * sigma_intercept, 
                                                dims=("subject", "coef_intercept"))
        beta_pref_sub = pm.Deterministic("beta_pref_sub", beta_pref + z_biases * sigma_pref, 
                                            dims=("subject", "coef_pref"))
        
        # Estimate the score of the bias (i.e. weighted sum of each of the biases regressors):
        preference = pm.Deterministic('preference', (beta_pref_sub[subj_idx] * preferences).sum(axis=-1))

        # Eta parameter is the weighted sum of the intercept, the bias, the planning values and 
        # the interaction between the entropy of the bias and the planning
        eta = (
            beta_intercept_sub[subj_idx, 0] * intercept
            + preference
        )
        
        # Expected values:
        p = pm.Deterministic("p", pm.math.sigmoid(eta))

        # Likelihood 
        pm.Bernoulli("y", p=p, observed=y_obs)

        # Sampling:
        traces['No_Planning'] = pm.sample(
            draws=n_samples,
            tune=n_warmup,
            chains=n_chains,
            target_accept=0.85,
            idata_kwargs={"log_likelihood": True},
        )
        # Save to file:
        az.to_netcdf(traces['No_Planning'], Path(control_models_path, "no_planning_model.nc"))

Model without interactions

Finally, we also test a model without interaction between preferences entropy and decision values.

In [6]:
if os.path.exists(Path(control_models_path, "no_interaction_model.nc")):
    idata = az.from_netcdf(Path(control_models_path, "no_interaction_model.nc"))
    traces['No_Interaction'] = idata
else:
    # Choice behaviour model with prefences only, no decision values:
    pref_regressors = beh_data[[
        'is_1', 'is_2', 'is_3', 'is_4', 
        'is_lc', 'is_hc', 
        'is_lfc', 'is_hfc',
        'e_is_0', 'e_is_1', 'e_is_2', 'e_is_3', 'e_is_4', 'e_is_5', 'e_is_6'
    ]]

    # Create intercept:
    intercept = np.ones(beh_data.shape[0])

    # Set coordinates:
    coords = {
        "subject": subj_labels,
        "coef_intercept": ["B_intercept"],
        "coef_planning": ["B_plan"],
        "coef_pref": ["B_" + col for col in pref_regressors.columns],
        "coef_interaction": ["slope"],
    }

    # Model:
    with pm.Model(coords=coords) as no_interaction_model:
        # Data:
        y_obs = pm.Data("y_obs", beh_data['response'])
        intercept = pm.Data("intercept", intercept)
        planning = pm.Data("planning", np.squeeze(beh_data[['dv']].to_numpy()))
        preferences = pm.Data("preferences", pref_regressors)
        subj_idx = pm.Data("subj_idx", subj_idx_raw.astype("int32"))

        # Hyperpriors:
        # Intercept term
        beta_intercept = pm.Normal("beta_intercept", mu=0, sigma=2, dims="coef_intercept")
        sigma_intercept = pm.HalfNormal("sigma_intercept", sigma=2, dims="coef_intercept")
        # Planning term:
        beta_planning = pm.Normal("beta_planning", mu=0, sigma=2, dims="coef_planning")
        sigma_planning = pm.HalfNormal("sigma_planning", sigma=2, dims="coef_planning")
        # Preference terms:
        beta_pref = pm.Normal("beta_pref", mu=0, sigma=2, dims="coef_pref")
        sigma_pref = pm.HalfNormal("sigma_pref", sigma=2, dims="coef_pref")

        # Offset parameters:
        z_intercept = pm.Normal("z_intercept", 0, 1, dims=("subject", "coef_intercept"))
        z_planning = pm.Normal("z_planning", 0, 1, dims=("subject", "coef_planning"))
        z_biases = pm.Normal("z_biases", 0, 1, dims=("subject", "coef_pref"))

        # Centered parameters:
        beta_intercept_sub = pm.Deterministic("beta_intercept_sub", beta_intercept + z_intercept * sigma_intercept, 
                                                dims=("subject", "coef_intercept"))
        beta_planning_sub = pm.Deterministic("beta_planning_sub", beta_planning + z_planning * sigma_planning, 
                                                dims=("subject", "coef_planning"))
        beta_pref_sub = pm.Deterministic("beta_pref_sub", beta_pref + z_biases * sigma_pref, 
                                            dims=("subject", "coef_pref"))
        
        # Estimate the score of the bias (i.e. weighted sum of each of the biases regressors):
        preference = pm.Deterministic('preference', (beta_pref_sub[subj_idx] * preferences).sum(axis=-1))

        # Eta parameter is the weighted sum of the intercept, the bias, the planning values and 
        # the interaction between the entropy of the bias and the planning
        eta = (
            beta_intercept_sub[subj_idx, 0] * intercept
            + beta_planning_sub[subj_idx, 0] * planning
            + preference
        )
        
        # Expected values:
        p = pm.Deterministic("p", pm.math.sigmoid(eta))

        # Likelihood 
        pm.Bernoulli("y", p=p, observed=y_obs)

        # Sampling:
        traces['No_Interaction'] = pm.sample(
            draws=n_samples,
            tune=n_warmup,
            chains=n_chains,
            target_accept=0.85,
            idata_kwargs={"log_likelihood": True},
        )

        az.to_netcdf(traces['No_Interaction'], Path(control_models_path, "no_interaction_model.nc"))

Comparing the models:

In [7]:
# Compare the models:
model_comparison = az.compare(traces)
az.plot_compare(model_comparison);
Figure S1: Results of preferences model comparison. Model abbreviations: (No_interaction) model comprising planning and preference component, without interaction between the two, (No_FC) model where future cost factor was omitted from preferences, (No_CC) model where future cost factor was omitted from preferences, (No_Planning) model where DV was omitted, (No_Energy) model where energy factor was omitted from preferences, (No_Offer) model where offer factor was omitted from preferences
In [8]:
# Print the model comparison:
display(Markdown(model_comparison[[ "elpd_loo", "p_loo", "elpd_diff", "se", "dse"]].to_markdown()))
Table S1
Results of the comparison between different preferences models
elpd_loo p_loo elpd_diff se dse
full_preferences_model -1491.09 253.49 0 48.6156 0
No_Interaction -1501.44 245.861 10.3472 48.9103 5.41809
No_FC -1517.79 227.725 26.7017 48.5354 7.5994
No_CC -1539.49 262.803 48.3996 49.8892 9.67725
No_Planning -1581.23 217.308 90.1381 48.8039 17.2262
No_Energy -1626.44 211.155 135.351 51.5682 17.8535
No_Offer -1629.84 235.203 138.745 54.4382 19.333

Frequency prior model comparison

In the main text, we compared the preference model with a model in which priors are computed based on action frequency. In this model, the priors are computed using a linearly decaying moving average, discounting actions that occured further in the past. We presented the results obtained when the priors are derived from the XXX previous trials. This number of trials was selected by exploring priors witk \(k=[2, 5, 10, 15, 20, 30, 50]\) to identify the values that fit the data the best:

In [9]:
traces = {}
for k in [2, 5, 10, 15, 20, 30, 50]:
    if os.path.exists(Path(control_models_path, f"linear_freq_prior_k{k}.nc")):
        idata = az.from_netcdf(Path(control_models_path, f"linear_freq_prior_k{k}.nc"))
        traces[f'Linear, k={k}'] = idata
    else:
        # Compute action priors:
        P_A = []
        for subj in beh_data['vpn'].unique():
            sub_resp = beh_data[beh_data['vpn'] == subj]['response'].to_numpy()
            P_A.append(linear_decay_mvavg(sub_resp, window_size=k, init_val=0.5))
        data = pd.DataFrame(
            {
            'response': beh_data['response'],
            'vpn': beh_data['vpn'],
            'dv': beh_data['dv'],
            'logitP_A': logit(np.clip(np.concatenate(P_A), 1e-3, 1-1e-3))
            }
        )
        action_prior_model = bmb.Model(
            "response ~ dv + logitP_A + dv:logitP_A +"
            " + (dv + logitP_A|vpn)",
                data, 
                family="bernoulli"
            )
        # Add the idata to the rest:
        traces[f'Linear, k={k}'] = action_prior_model.fit(
            draws=n_samples, tune=n_warmup, chains=n_chains, target_accept=0.85, idata_kwargs={"log_likelihood": True}
        )

        # Save the trace to file:
        az.to_netcdf(traces[f'Linear, k={k}'], Path(control_models_path, f"linear_freq_prior_k{k}.nc"))              
In [10]:
# Compare the models:
model_comparison = az.compare(traces)
az.plot_compare(model_comparison);                 
Figure S2: Results of frequency model comparison, comparing the temporal integration window, going back k=[2, 5, 10, 15, 20, 30, 50]
In [11]:
# Print the model comparison:
display(Markdown(model_comparison[[ "elpd_loo", "p_loo", "elpd_diff", "se", "dse"]].to_markdown()))
Table S2
Results of the frequency model comparisons with different temporal integration windows
elpd_loo p_loo elpd_diff se dse
Linear, k=20 -2069.6 78.3873 0 55.6858 0
Linear, k=15 -2073.74 74.2703 4.13799 55.8878 5.24083
Linear, k=2 -2074.98 75.5677 5.37448 55.6833 6.29665
Linear, k=5 -2076.3 78.84 6.69933 55.6484 5.58139
Linear, k=10 -2077.34 74.0643 7.73213 55.5341 5.65329
Linear, k=50 -2080.71 78.7902 11.1052 55.7161 5.09526
Linear, k=30 -2081.1 75.786 11.4922 55.7602 5.46973