Skip to content

Commit 02c8c3c

Browse files
committed
Arbitrary mixture models in gddm() interface
1 parent 093161c commit 02c8c3c

5 files changed

Lines changed: 96 additions & 20 deletions

File tree

.github/workflows/buildwheel.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ jobs:
77
runs-on: ${{ matrix.os }}
88
strategy:
99
matrix:
10-
os: [windows-latest, macos-latest, ubuntu-20.04]
10+
os: [windows-latest, macos-latest, ubuntu-22.04]
1111
steps:
1212
- uses: actions/checkout@v3
1313

.github/workflows/python-package.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ on:
99
jobs:
1010
build:
1111

12-
runs-on: ubuntu-20.04
12+
runs-on: ubuntu-22.04
1313
strategy:
1414
fail-fast: false
1515
matrix:

pyddm/functions.py

Lines changed: 90 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -705,7 +705,7 @@ def display_component(component, prefix=""):
705705
else:
706706
print(OUT)
707707

708-
def gddm(drift=0, noise=1, bound=1, nondecision=0, starting_position=0, mixture_coef=.02, name="", parameters={}, conditions=[], dx=param.dx, dt=param.dt, T_dur=param.T_dur, choice_names=param.choice_names):
708+
def gddm(drift=0, noise=1, bound=1, nondecision=0, starting_position=0, mixture_coef=.02, mixture_distribution=None, name="", parameters={}, conditions=[], dx=param.dx, dt=param.dt, T_dur=param.T_dur, choice_names=param.choice_names):
709709
"""Return a model without the use of PyDDM's object-oriented interface.
710710
711711
PyDDM has two interfaces: this one (the gddm function), and the
@@ -783,10 +783,31 @@ def gddm(drift=0, noise=1, bound=1, nondecision=0, starting_position=0, mixture_
783783
return a vector of the same size as "T". Otherwise, non-decision time
784784
will be assumed to be a single point.
785785
786-
- `mixturecoef`: The uniform distribution mixture model, used to allow
787-
likelihood fitting. Can not be given by a function, and must be either a
788-
constant or a parameter. The uniform mixture model can be disabled by
789-
setting this to 0.
786+
- `mixture_coef`: The probability of a "lapse" trial, i.e., the probability
787+
of sampling from a distribution other than the DDM (by default a uniform
788+
distribution). This is used to facilitate likelihood fitting. This can be
789+
either a constant, a parameter, or a function. The mixture model can be
790+
disabled by setting this to 0. If a function, it can accept parameters,
791+
conditions, or "upper", a boolean which allows different mixture
792+
coefficients for the upper and lower boundaries, and return a number 0-1.
793+
If "upper" is not an argument, the return value should be interpreted as
794+
the overall probability of a lapse, i.e., sampling from the mixture
795+
distribution instead of the DDM. If "upper" is an argument, the return
796+
value should be interpreted as the probability of a lapse to the given
797+
side, and the sum of the return values for upper=True and upper=False
798+
should never be greater than 1. By default, the mixture model uses a
799+
uniform distribution, but this can be changed with the
800+
"mixture_distribution" parameter.
801+
802+
- `mixture_distribution`: Use a distribution other than the uniform
803+
distribution as a mixture model. This should still integrate to 1
804+
(i.e. sum to 1/dt). If it integrates to less than 1, these will be
805+
considered undecided trials. If it integrates to more than 1, it will
806+
throw an error. This should accept the argument "T", a vector of times
807+
from 0 to T_dur, and return a vector of the same size as "T". It may
808+
optionally accept the argument "upper", where the distribution may be
809+
specified separately for the upper and lower boundaries. If "upper" is
810+
specified, the two sides must each integrate to 1 on their own.
790811
791812
Other parameters:
792813
@@ -818,14 +839,14 @@ def _parse_dep(val, name, special="xt"):
818839
"""Determine whether `val` can be turned into a PyDDM model, and if so, parse relevant information.
819840
820841
`name` is the dependence name for error message outputs.
821-
`special` is either "", "x", "t", "T", or "xt", describing what the dependence supports.
842+
`special` is either "", "x", "t", "T", "xt", or "uT" describing what the dependence supports.
822843
"""
823844
if val in conditions:
824845
val = eval(f"lambda {val}: {val}")
825846
if val in parameters.keys():
826-
return "val",None,_fittables[val]
847+
return "val",([],[],[]),_fittables[val]
827848
elif isinstance(val, (int,float,np.floating,np.integer)):
828-
return "val",None,val
849+
return "val",([],[],[]),val
829850
elif hasattr(val, "__call__"):
830851
sig = inspect.getfullargspec(val)
831852
assert len(sig.kwonlyargs) == 0, f"Keyword only args not supported for {name}"
@@ -841,10 +862,11 @@ def _parse_dep(val, name, special="xt"):
841862
_required_parameters.append(arg)
842863
elif arg in conditions:
843864
_required_conditions.append(arg)
844-
elif arg in ["x", "t", "T"]:
865+
elif arg in ["x", "t", "T", "upper"]:
845866
_descr = {"x": "the vector of particle positions",
846867
"t": "the current time in the simulation",
847-
"T": "the vector of all time points in the simulation"}
868+
"T": "the vector of all time points in the simulation",
869+
"upper": "the side of the distribution (for mixture models)"}
848870
assert arg in special, f"In PyDDM, the '{arg}' argument usually indicates {_descr[arg]}, but this argument cannot be used in the {name} function."
849871
_required_xt.append(arg)
850872
else:
@@ -981,11 +1003,63 @@ def get_nondecision_time(self, conditions):
9811003
return nondecision(**{v: getattr(self, v) for v in _required_parameters_nd}, **{v: conditions[v] for v in _required_conditions_nd})
9821004
overlayobjs.append(OverlayNonDecisionEasy(**{fname:fval for fname,fval in _fittables.items() if fname in _required_parameters_nd}))
9831005

984-
typ, parsed, mixture_coef = _parse_dep(mixture_coef, "mixture_coef", "")
1006+
# Split mixture distribution into two parts: the mixture coefficient, and the mixture distribution
1007+
mixture_coef_name = mixture_coef
1008+
_mixc_typ, parsed, mixture_coef = _parse_dep(mixture_coef, "mixture_coef", ["upper"])
1009+
_required_parameters_mixc,_required_conditions_mixc,_required_u_mixc = parsed
1010+
if isinstance(mixture_coef, Fittable):
1011+
_required_parameters_mixc = list(set(_required_parameters_mixc+[mixture_coef_name]))
9851012
# If mixture coefficient is a parameter or value
986-
if typ == "val":
987-
overlayobjs.append(OverlayUniformMixture(umixturecoef=mixture_coef))
988-
# If it is a function
989-
elif typ == "func":
990-
raise ValueError("mixture_coef cannot be a function here, please use the full object oriented version of PyDDM for this functionality.")
1013+
if mixture_distribution is None:
1014+
mixture_distribution = lambda T : np.ones(len(T))/(np.max(T)+T[1]-T[0])
1015+
typ, parsed, mixture_dist = _parse_dep(mixture_distribution, "mixture_distribution", ["upper", "T"])
1016+
if typ == "func":
1017+
_required_parameters_mix,_required_conditions_mix,_required_uT_mix = parsed
1018+
if "T" in _required_uT_mix:
1019+
class OverlayMixtureEasy(Overlay):
1020+
name = "easy_mixture_model"
1021+
required_parameters = list(set(_required_parameters_mix+_required_parameters_mixc))
1022+
required_conditions = list(set(_required_conditions_mix+_required_conditions_mixc))
1023+
def apply(self, solution):
1024+
assert isinstance(solution, Solution)
1025+
choice_upper = solution.choice_upper
1026+
choice_lower = solution.choice_lower
1027+
m = solution.model
1028+
cond = solution.conditions
1029+
undec = solution.undec
1030+
evolution = solution.evolution
1031+
print(_mixc_typ, mixture_coef, mixture_coef_name)
1032+
if _mixc_typ == "val":
1033+
if isinstance(mixture_coef, Fittable):
1034+
mcoef = lambda **kwargs : kwargs[mixture_coef_name]
1035+
else:
1036+
mcoef = lambda : mixture_coef
1037+
elif _mixc_typ == "func":
1038+
mcoef = mixture_coef
1039+
T = m.dt * np.arange(0, len(choice_upper)) + m.dt/2
1040+
if "upper" in _required_uT_mix:
1041+
lapses_upper = mixture_distribution(**{v: getattr(self, v) for v in _required_parameters_mix}, **{v: cond[v] for v in _required_conditions_mix}, T=T, upper=True)
1042+
lapses_lower = mixture_distribution(**{v: getattr(self, v) for v in _required_parameters_mix}, **{v: cond[v] for v in _required_conditions_mix}, T=T, upper=False)
1043+
else:
1044+
lapses_upper = mixture_distribution(**{v: getattr(self, v) for v in _required_parameters_mix}, **{v: cond[v] for v in _required_conditions_mix}, T=T)
1045+
lapses_lower = lapses_upper
1046+
if "upper" in _required_u_mixc:
1047+
mix_coef_upper = mcoef(**{v: getattr(self, v) for v in _required_parameters_mixc}, **{v: cond[v] for v in _required_conditions_mixc}, upper=True)
1048+
mix_coef_lower = mcoef(**{v: getattr(self, v) for v in _required_parameters_mixc}, **{v: cond[v] for v in _required_conditions_mixc}, upper=False)
1049+
else:
1050+
mix_coef_upper = .5*mcoef(**{v: getattr(self, v) for v in _required_parameters_mixc}, **{v: cond[v] for v in _required_conditions_mixc})
1051+
mix_coef_lower = mix_coef_upper
1052+
if np.sum(lapses_upper)*m.dt > 1.0 or np.sum(lapses_lower)*m.dt > 1.0:
1053+
if np.sum(lapses_upper)*m.dt > 1.01 or np.sum(lapses_lower)*m.dt > 1.01:
1054+
print("Renormalising mixture distribution to integrate to 1, this may be a bug in your mixture_distribution function")
1055+
lapses_upper /= np.sum(lapses_upper)*m.dt
1056+
lapses_lower /= np.sum(lapses_lower)*m.dt
1057+
assert mix_coef_upper + mix_coef_lower <= 1.001, f"Mixture coefficients cannot be > 1, currently are {mix_coef_upper} and {mix_coef_lower}"
1058+
choice_upper = choice_upper*(1-(mix_coef_upper+mix_coef_lower)) + lapses_upper*mix_coef_upper*m.dt
1059+
choice_lower = choice_lower*(1-(mix_coef_upper+mix_coef_lower)) + lapses_lower*mix_coef_lower*m.dt
1060+
return Solution(choice_upper, choice_lower, m, cond, undec, evolution)
1061+
overlayobjs.append(OverlayMixtureEasy(**{fname:fval for fname,fval in _fittables.items() if fname in list(set(_required_parameters_mix+_required_parameters_mixc))}))
1062+
else:
1063+
raise ValueError("Mixture distribution must be a function, or else None for the uniform distribution")
1064+
9911065
return Model(drift=driftobj, noise=noiseobj, bound=boundobj, IC=icobj, overlay=OverlayChain(overlays=overlayobjs), dx=dx, dt=dt, T_dur=T_dur, choice_names=choice_names, name=name)

pyddm/plot.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,8 @@ def __call__(self, x, pos=None):
237237
ax1.yaxis.set_major_formatter(NonZeroScalarFormatter())
238238
for l in ax1.get_xticklabels():
239239
l.set_visible(False)
240+
for l in ax2.get_xticklabels():
241+
l.set_visible(True)
240242
ax1.spines['left'].set_position(('outward', 10))
241243
ax2.spines['left'].set_position(('outward', 10))
242244
ax2.spines['bottom'].set_position(('outward', 10))

pyddm/sample.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,10 +204,10 @@ def from_numpy_array(data, column_names=[], choice_names=("correct", "error")):
204204
work with undecided trials.
205205
"""
206206
assert len(column_names) == data.shape[1] - 2, "Invalid number of column names for conditions"
207-
undecided = np.isnan(data[:,0]) & np.isnan(data[:,1])
207+
undecided = np.isnan(data[:,0].astype(float)) & np.isnan(data[:,1].astype(float))
208208
undecided_data = data[undecided]
209209
data = data[~undecided]
210-
assert not np.any(np.isnan(data[:,0:2])), "First two columns must be either both nan (for undecided trials) not neither nan"
210+
assert not np.any(np.isnan(data[:,0:2].astype(float))), "First two columns must be either both nan (for undecided trials) not neither nan"
211211
c = data[:,1].astype(bool)
212212
nc = (1-data[:,1]).astype(bool)
213213
def pt(x): # Pythonic types

0 commit comments

Comments
 (0)