Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions design/response_domains.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,15 @@ part of this contract.
3. HSSM has one normalization boundary. Legacy `choices` can create a categorical domain
only when there is exactly one non-RT response column. Multiple response columns
require canonical metadata.
4. Canonical metadata combined with explicitly supplied `choices` is ambiguous and
fails. Resolved configs have no implicit choice default: built-in factories supply
their existing labels explicitly.
4. Canonical metadata combined with explicitly supplied `choices` at a raw input
boundary is ambiguous and fails. A resolved config may retain an exactly matching
derived `choices` view so ordinary dataclass copying and validation remain
idempotent; conflicting values fail. Resolved configs have no implicit choice
default: built-in factories supply their existing labels explicitly. Resolved
configs are construction snapshots, not mutable domain registries. Directly
mutating nested canonical metadata is unsupported and a later validation fails if
the derived view no longer matches; construct a replacement with canonical metadata
and `choices=None` instead.
5. On `main`, `choices` is derived only for exactly one categorical domain. It is `None`
for continuous, circular, or multiple domains, including multiple categorical
domains.
Expand Down
13 changes: 11 additions & 2 deletions src/hssm/_types.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Type definitions for the HSSM package."""

from os import PathLike
from typing import Any, Callable, Literal, Optional, TypedDict, Union
from typing import Any, Callable, Literal, NotRequired, Optional, TypedDict, Union

import bambi as bmb
import numpy as np
Expand Down Expand Up @@ -48,12 +48,21 @@ class LoglikConfig(TypedDict):
LoglikConfigs = dict[LoglikKind, LoglikConfig]


class ResponseDomainSpec(TypedDict):
"""Canonical metadata for one physical response column."""

kind: Literal["categorical", "continuous", "circular"]
values: NotRequired[tuple[int, ...]]
bounds: NotRequired[tuple[float, float]]


class DefaultConfig(TypedDict):
"""Type for the value of DefaultConfig."""

response: list[str]
list_params: list[str]
choices: list[int]
choices: NotRequired[list[int]]
response_domains: NotRequired[dict[str, ResponseDomainSpec]]
description: Optional[str]
likelihoods: LoglikConfigs

Expand Down
26 changes: 22 additions & 4 deletions src/hssm/addm/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@
"""

from collections.abc import Callable
from copy import deepcopy
from dataclasses import dataclass, field, fields
from typing import Any

from .._types import LoglikKind
from ..config import BaseModelConfig
from ..config import BaseModelConfig, _resolve_response_domains
from .attention_process import resolve_attention_process


Expand Down Expand Up @@ -60,7 +61,7 @@ class aDDMConfig(BaseModelConfig):
model_name: str = "addm"
description: str | None = "Attentional Drift Diffusion Model"
response: list[str] = field(default_factory=lambda: ["rt", "response"])
choices: tuple[int, ...] = (-1, 1)
choices: tuple[int, ...] | None = (-1, 1)
list_params: list[str] = field(
default_factory=lambda: ["eta", "kappa", "a", "b", "x0", "t"]
)
Expand Down Expand Up @@ -90,10 +91,17 @@ class aDDMConfig(BaseModelConfig):
continuation_mode: str = "prolong_last_fixation"
continuation_params: dict | None = None
# ``loglik`` and ``backend`` are inherited (default ``None``) and injected by
# ``aDDM.__init__`` via ``dataclasses.replace`` in Commit 4 — not redeclared here.
# ``aDDM.__init__`` via ``dataclasses.replace`` — not redeclared here.

def validate(self) -> None:
"""Validate the configuration (mirrors ``RLSSMConfig.validate``)."""
response_domains, choices = _resolve_response_domains(
self.response, self.response_domains, self.choices
)
if choices is None:
raise ValueError("aDDM requires one categorical response domain.")
self.response_domains = response_domains
self.choices = choices
if not self.list_params:
raise ValueError("Please provide `list_params` in the configuration.")
# Raises ValueError (unknown name) / TypeError (bad type) on failure.
Expand Down Expand Up @@ -128,5 +136,15 @@ def get_defaults(self, param: str) -> tuple[None, tuple[float, float] | None]:
@classmethod
def from_addm_dict(cls, config_dict: dict[str, Any]) -> "aDDMConfig":
"""Build an ``aDDMConfig`` from a dict, ignoring unknown keys."""
if (
config_dict.get("response_domains") is not None
and config_dict.get("choices") is not None
):
raise ValueError(
"Provide either `response_domains` or legacy `choices`, not both."
)
field_names = {f.name for f in fields(cls)}
return cls(**{k: v for k, v in config_dict.items() if k in field_names})
init_kwargs = {k: v for k, v in config_dict.items() if k in field_names}
if init_kwargs.get("response_domains") is not None:
init_kwargs["response_domains"] = deepcopy(init_kwargs["response_domains"])
return cls(**init_kwargs)
44 changes: 34 additions & 10 deletions src/hssm/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import logging
import warnings
from abc import ABC, abstractmethod
from copy import deepcopy
from copy import copy, deepcopy
from os import PathLike
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Literal, Union, cast
Expand Down Expand Up @@ -309,7 +309,8 @@ def __init__(
self.initval_jitter = initval_jitter

# region ===== Store the pre-built config =====
self.model_config: BaseModelConfig = model_config
self.model_config: BaseModelConfig = copy(model_config)
self.model_config.response_domains = deepcopy(model_config.response_domains)
# endregion

# region ===== Set up shortcuts so old code will work ======
Expand All @@ -324,6 +325,7 @@ def __init__(
else None
)
self.choices = self.model_config.choices # type: ignore[assignment]
self.response_domains = self.model_config.response_domains or {}
self.model_name = self.model_config.model_name
self.loglik = self.model_config.loglik
self.loglik_kind = self.model_config.loglik_kind
Expand All @@ -333,21 +335,14 @@ def __init__(
# TODO: add to HSSMBase
self.is_choice_only: bool = self.model_config.is_choice_only

if self.choices is None:
raise ValueError(
"`choices` must be provided either in `model_config` or as an argument."
)

self._validate_choices()

# region Avoid mypy error later (None.append). Should list_params be Optional?
if self.list_params is None:
raise ValueError(
"`list_params` must be provided in the model configuration."
)
# endregion

self.n_choices = len(self.choices) # type: ignore[arg-type]
self.n_choices = len(self.choices) if self.choices is not None else None

self._pre_check_data_sanity()

Expand Down Expand Up @@ -565,6 +560,23 @@ def _store_init_args(
exclude_keys = {"self", "kwargs", "__class__"}
result = {k: v for k, v in local_vars.items() if k not in exclude_keys}
result.update(extra_kwargs)
model_config = result.get("model_config")
if (
isinstance(model_config, dict)
and model_config.get("response_domains") is not None
):
model_config = model_config.copy()
model_config["response_domains"] = deepcopy(
model_config["response_domains"]
)
result["model_config"] = model_config
elif (
model_config is not None
and getattr(model_config, "response_domains", None) is not None
):
model_config = copy(model_config)
model_config.response_domains = deepcopy(model_config.response_domains)
result["model_config"] = model_config
return result

def find_MAP(self, **kwargs):
Expand Down Expand Up @@ -1958,8 +1970,20 @@ def _check_lapse(self, lapse):
+ "parameter is not None"
)
if self.has_lapse:
domain = (
next(iter(self.response_domains.values()))
if len(self.response_domains) == 1
else None
)
supports_lapse = domain is not None and domain["kind"] == "categorical"
if not supports_lapse:
raise ValueError(
"`p_outlier` is supported only for one categorical response "
"column. Set `p_outlier=None` or `p_outlier=0` for this model."
)
if lapse is None:
if self.is_choice_only:
assert self.n_choices is not None
self.lapse = 1 / self.n_choices
else:
self.lapse = bmb.Prior("Uniform", lower=0.0, upper=20.0)
Expand Down
Loading