Repository: wichmann-lab/python-psignifit

Issue:
Add a historical paragraph to the docs
We need a paragraph in the docs explaining the history of psignifit for people confused by the different versions that are available online and returned by Google search

Hints from issue comments:
@FelixWichmann: I send it to you yesterday, Tiziano.
Cheers, Felix

> On 10. Jan 2025, at 12:40, Tiziano Zito ***@***.***> wrote:
> 
> 
> We need a paragraph in the docs explaining the history of psignifit for people confused by the different versions that are available online and returned by Google search
> —
> Reply to this email directly, view it on GitHub, or unsubscribe.
> You are receiving this because you are subscribed to this thread.Message ID: ***@***.***>

@otizonaizit: > I send it to you yesterday, Tiziano. Cheers, Felix

sure, I created the issue to keep track of the progress and close the issue when it's done ;-)

Solution patch:
diff --git a/docs/_config.yml b/docs/_config.yml
index 62c788c..ad02f84 100644
--- a/docs/_config.yml
+++ b/docs/_config.yml
@@ -4,10 +4,11 @@
 title: Psignifit
 author: Psignifit contributors
 #logo: logo.png
-copyright: "2024. Licence: GPLv2"  # Copyright year to be placed in the footer
+copyright: "2025. Licence: GPLv3+"  # Copyright year to be placed in the footer
 
 execute:
   execute_notebooks: force
+  timeout: -1
 
 #only_build_toc_files        : true
 
@@ -51,6 +52,7 @@ sphinx:
      "member-order": 'bysource',     # Order members as in source file.
     }
     exclude_patterns: ['_build']
+    nb_execution_show_tb: True
     intersphinx_mapping:
         python:
           - 'https://docs.python.org/3/'
diff --git a/docs/examples/advanced-options.md b/docs/examples/advanced-options.md
index 20cd50b..b5428cf 100644
--- a/docs/examples/advanced-options.md
+++ b/docs/examples/advanced-options.md
@@ -118,7 +118,7 @@ The definition of the `width` parameter of a psychometric function can be change
 
 `width = `$\Psi^{-1}(1-\alpha) - \Psi^{-1}(\alpha)$ where $\Psi^{-1}$ is the inverse of the sigmoid function.
 
-`width_alpha` must be between 0 and .5 excluding. Default is `width=0.05`
+`width_alpha` must be between 0 and .5 excluding. Default is `width_alpha=0.05`
 
 For example, this would enable the usage of the interval from .1 to .9 as the width
 
diff --git a/docs/index.md b/docs/index.md
index 6bdb348..753be53 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -25,18 +25,76 @@ First [install](install_guide) the package.
 
 Then, check out the [basic usage](basic-usage) page.
 
-## Acknowledgements
+## A brief history of *psignifit*
+
+The development of *psignifit* began in the mid 1990's by Felix Wichmann when
+he worked for his doctoral degree at the University of Oxford.
+
+The first version (*psignifit* `1`) was programmed in Mathematica, but was soon ported
+to MATLAB. Shortly thereafter Jeremy Hill joined the development and wrote
+the C code (MEX files), executing the numerically intensive parts of the code
+and thereby speeding up *psignifit* `2` enormously. This version implemented
+maximum likelihood (ML) parameter estimation and a parametric bootstrap for
+confidence intervals, based on two papers by Felix Wichmann and Jeremy Hill:
+
+  - Wichmann, F. A. and Hill, N. J. (2001). [The psychometric function: I. Fitting, sampling and goodness-of-fit](https://link.springer.com/article/10.3758/BF03194544). *Perception and Psychophysics*, 63(8), 1293-1313.
+  - Wichmann, F. A. and Hill, N. J. (2001). [The psychometric function: II. Bootstrap-based confidence intervals and sampling](https://link.springer.com/article/10.3758/BF03194545). *Perception and Psychophysics*, 63(8), 1314-1329.
+
+A copy of the original website and code is still [available](https://uni-tuebingen.de/fakultaeten/mathematisch-naturwissenschaftliche-fakultaet/fachbereiche/informatik/lehrstuehle/neuronale-informationsverarbeitung/research/software/psignifit-legacy/).
+
+*psignifit* `3` was developed by Ingo Fründ, Valentin Hänel and Felix Wichmann at the
+TU Berlin between 2009 and 2012 using a mix of Python and C/C++, inspired by Malte Kuss, Frank Jäkel and
+Felix Wichmann's Bayesian psychometric function fitting package PsychoFun written in R:
+
+  - Kuss, M., Jäkel, F. and Wichmann, F. A. (2005). [Bayesian inference for psychometric functions](http://jov.arvojournals.org/article.aspx?articleid=2192844). *Journal of Vision*, 5, 478-492
+  - Fründ, I., Haenel, N. V. and Wichmann, F.A. (2011). [Inference for psychometric functions in the presence of nonstationary behavior](http://jov.arvojournals.org/article.aspx?articleid=2121082). *Journal of Vision*, 11(6:16), 1-19.
 
-The first implementation, as Python-clone of the original MATLAB library, was done by Sophie Laturnus and Ole Fortmann. 
+The goal of the re-write was twofold: First, to move from ML parameter
+estimation and bootstrapping to Bayesian inference. Second, to move from MATLAB
+to Python as programming language. However, it is important to note that the
+MCMC sampling used for Bayesian inference in *psignifit* `3` is not always reliable,
+and further development of *psignifit* `3` was stopped. The original website is still
+[online](https://psignifit.sourceforge.net/) though.
 
-The current completely rewritten implementation is the result of a programming sprint in September 2024 and later work. The participants were David-Elias Künstle, Felix Wichmann, Guillermo Aguilar, Lisa Schwetlick, Pietro Berkes and Tiziano Zito. The complete list of [contributors](https://github.com/wichmann-lab/python-psignifit/blob/main/CONTRIBUTORS).
 
-We would like to thank previous members of the Wichmann-lab who
-were involved in developing MCMC based methods of Bayesian inference for
-the psychometric function, most notably Ingo Fründ, Valentin Hänel,
-Frank Jäkel and Malte Kuss.
+For the latest *psignifit* `4` version, development was shifted back to MATLAB.
+Heiko Schütt implemented Bayesian inference based on numerical integration rather than MCMC
+sampling, helped by Stefan Harmeling and Felix Wichmann as well as statistical
+support by Jakob Macke. Numerical integration avoids the reliability issues
+plaguing *psignifit* `3`. Furthermore, *psignifit* `4` is fast enough to be used without
+MEX files. These two changes–avoiding the pain of MCMC chain diagnosis and
+the pain of MEX file compilation–are alluded to in the title of the paper
+describing the method:
+
+- Schütt, H. H., Harmeling, S., Macke, J. H. and Wichmann, F. A. (2016). [Painfree and accurate Bayesian estimation of psychometric functions for (potentially) overdispersed data](http://www.sciencedirect.com/science/article/pii/S0042698916000390). *Vision Research* 122, 105-123.
+
+A final important statistical development of *psignifit* `4` was the move from
+a purely binomial to a beta-binomial model, allowing the estimation of 
+overdispersion, i.e. of the degree to which human (or animal) observers are more "noisy"
+than an idealised binomial decision maker. The original *psignifit* `4` [MATLAB implementation](https://github.com/wichmann-lab/psignifit)
+is available on [GitHub](https://github.com/wichmann-lab/psignifit).
+
+The first implementation of *psignifit* `4` in Python started in 2016 as a pure
+Python-clone of the original MATLAB library, implemented by Sophie Laturnus, Ole Fortmann
+and Heiko Schütt. In 2017 Tiziano Zito started a complete rewrite, with the intent
+of making the code more "pythonic" and making use of the SciPy statistical libraries switching
+away from self-written ones. The rewrite was taken over by David-Elias Künstle in 2020, who
+did most of the hard work of refactoring and implementing the missing functionality. Künstle's
+and Zito's work lived for a long time unfinished in a `refactor-api` branch on the
+[public GitHub repo](https://github.com/wichmann-lab/python-psignifit) without seeing the light
+of the day.
+
+In 2024 David-Elias Künstle, Guillermo Aguilar, Lisa Schwetlick, Pietro Berkes
+and Tiziano Zito got together at a programming sprint and with a few bits of advise from Heiko
+Schütt and Felix Wichmann after a couple of days of sweat and tears finally published the first
+official release of the completely rewritten [implementation](https://github.com/wichmann-lab/python-psignifit/releases/tag/v4.1)
+of the [*Vision Research* paper](http://www.sciencedirect.com/science/article/pii/S0042698916000390).
+
+Since then the *psignifit* Python library is maintained by several [contributors](https://github.com/wichmann-lab/python-psignifit/blob/main/CONTRIBUTORS). 
+
+## Acknowledgements
 
-Furthermore, our thanks go to the reviewers of our manuscript and the
+Our thanks go to the reviewers of our manuscript and the
 students and colleagues who read the paper or tested the software and
 provided feedback: Nicole Eichert, Frank Jäkel, David Janssen, Britta
 Lewke, Lars Rothkegel, Joshua Solomon, Tom Wallis, Uli Wannek, Christian
diff --git a/psignifit/_configuration.py b/psignifit/_configuration.py
index 5008d8d..4385c5a 100644
--- a/psignifit/_configuration.py
+++ b/psignifit/_configuration.py
@@ -131,8 +131,36 @@ class Configuration:
             vkeys = _PARAMETERS
             if vkeys < set(value.keys()):
                 raise PsignifitException(
-                    f'Option fixed_paramters keys must be in {vkeys}. Given {list(value.keys())}!'
+                    f'Option fixed_parameters keys must be in {vkeys}. Given {list(value.keys())}!'
                 )
+            # Check that the fixed parameters have been set to meaningful values
+            for parm_name, parm_value in value.items():
+                # ERRORS
+                prefix = f'Fixed parameter {parm_name} must be strictly'
+                suffix = f': got {parm_name}={parm_value} instead!'
+                if parm_name == 'width' and parm_value <= 0:
+                    # 0 < width < +inf
+                    raise PsignifitException(f'{prefix} > 0 {suffix}')
+                elif parm_name == 'eta' and not(0 <= parm_value <= 1):
+                    # 0 <= eta <= 1
+                    raise PsignifitException(f'{prefix} 0 <= eta <= 1 {suffix}')
+                elif parm_name in ('gamma', 'lambda') and not(0 <= parm_value < 1):
+                    # 0 <= gamma < 1 and 0 <= lambda < 1
+                    raise PsignifitException(f'{prefix} 0 <= {parm_name} < 1 {suffix}')
+                # WARNINGS
+                if parm_name == 'lambda' and parm_value > 0.2:
+                    # lambda > 0.2 is unusual
+                    warnings.warn(f"You have fixed the lapse rate lambda to an unusually high value ({parm_value}). "
+                                  f"In typical psychophysical experiments lambda is rarely above 0.2 for human observers.")
+
+            if 'gamma' in value and 'lambda' in value:
+                gamma, lambda_ = value['gamma'], value['lambda']
+                # this condition can only happen if both lambda and gamma are fixed
+                # 0 <= gamma + lambda < 1
+                if not (0 <= gamma+lambda_ < 1):
+                    raise PsignifitException(f'For gamma and lambda the condition'
+                                             f' 0 <= gamma + lambda < 1 must always apply:'
+                                             f' got gamma={gamma}, lambda={lambda_} instead!')
 
 
     def check_experiment_type_matches_fixed_parameters(self, fixed_params, experiment_type):
@@ -140,7 +168,7 @@ class Configuration:
             if fixed_params is not None and 'gamma' in fixed_params:
                 warnings.warn(
                     f'The parameter gamma was fixed to {fixed_params["gamma"]}. In {ExperimentType.N_AFC.value} experiments gamma is automatically fixed to 1/n. Ignoring fixed gamma.')
-        if experiment_type == ExperimentType.EQ_ASYMPTOTE.value:
+        elif experiment_type == ExperimentType.EQ_ASYMPTOTE.value:
             if fixed_params is not None:
                 if 'gamma' in fixed_params:
                     pass # we assume the user knows what they are doing
@@ -152,6 +180,12 @@ class Configuration:
                             f'The parameters lambda {fixed_params["lambda"]} and'
                             f' gamma {fixed_params["gamma"]} were fixed to different values.'
                             f' In {experiment_type} experiments gamma and lambda need to be equal. ')
+        elif experiment_type == ExperimentType.YES_NO.value and fixed_params is not None:
+            if (gamma := fixed_params.get('gamma')) and gamma > 0.2:
+                    # gamma > 0.2 for yes/no experiments is unusual
+                    warnings.warn(f"You have fixed the guess rate gamma to an unusually high value ({gamma}). "
+                                  f"In typical psychophysical experiment of type 'yes/no' gamma is "
+                                  f"rarely above 0.2 for human observers.")
 
 
     def check_experiment_type(self, value):


Test patch:
diff --git a/tests/test_configuration.py b/tests/test_configuration.py
index 0eaa029..a2fffdd 100644
--- a/tests/test_configuration.py
+++ b/tests/test_configuration.py
@@ -1,11 +1,14 @@
 import dataclasses
 from unittest.mock import patch
 
+import numpy as np
 import pytest
 
 from psignifit._configuration import Configuration
 from psignifit._utils import PsignifitException
 
+EPSNEG = -np.finfo(np.float64).epsneg
+EPSPOS = np.finfo(np.float64).eps
 
 def test_setting_valid_option():
     c = Configuration(verbose=20)
@@ -138,3 +141,51 @@ def test_warning_for_equal_asymptote_fixing_lambda_and_gamma():
         Configuration(**options)
 
 
+def test_fixed_parm_width():
+    # 0 < width < +inf
+    with pytest.raises(PsignifitException, match='width'):
+        Configuration(fixed_parameters={'width': 0})
+    with pytest.raises(PsignifitException, match='width'):
+        Configuration(fixed_parameters={'width': EPSNEG})
+
+
+def test_fixed_parm_eta():
+    # 0 <= eta <= 1
+    with pytest.raises(PsignifitException, match='eta'):
+        Configuration(fixed_parameters={'eta': EPSNEG})
+    with pytest.raises(PsignifitException, match='eta'):
+        Configuration(fixed_parameters={'eta': 1+EPSPOS})
+
+
+def test_fixed_parm_gamma():
+    # 0 <= gamma < 1
+    with pytest.raises(PsignifitException, match='gamma'):
+        Configuration(fixed_parameters={'gamma': EPSNEG})
+    with pytest.raises(PsignifitException, match='gamma'):
+        Configuration(fixed_parameters={'gamma': 1})
+    # gamma > 0.2 and yes/no is unusual
+    with pytest.warns(UserWarning, match='unusual'):
+        Configuration(fixed_parameters={'gamma': 0.2+EPSPOS}, experiment_type='yes/no')
+
+
+def test_fixed_parm_lambda():
+    # 0 <= lambda < 1
+    with pytest.raises(PsignifitException, match='lambda'):
+        Configuration(fixed_parameters={'lambda': EPSNEG})
+    with pytest.raises(PsignifitException, match='lambda'):
+        Configuration(fixed_parameters={'lambda': 1})
+    # lambda > 0.2 is unusual
+    with pytest.warns(UserWarning, match='unusual'):
+        Configuration(fixed_parameters={'lambda': 0.2+EPSPOS})
+
+
+def test_fixed_parm_gamma_lambda():
+    # 0 <= gamma + lambda < 1
+    with pytest.raises(PsignifitException, match=r'gamma \+ lambda'):
+        Configuration(fixed_parameters={'gamma': 0.9, 'lambda': 0.2})
+    # gamma == 0 and lambda == 2 should fail!
+    with pytest.raises(PsignifitException):
+        Configuration(fixed_parameters={'gamma': 0, 'lambda': 2})
+    # gamma == 2 and lambda == 0 should fail!
+    with pytest.raises(PsignifitException):
+        Configuration(fixed_parameters={'gamma': 2, 'lambda': 0})
diff --git a/tests/test_numpy_scalars.py b/tests/test_numpy_scalars.py
index 7d11c93..38cb14b 100644
--- a/tests/test_numpy_scalars.py
+++ b/tests/test_numpy_scalars.py
@@ -36,7 +36,7 @@ def test_py_not_np_scalar_ci(input_data, ci_method):
 
 @pytest.mark.parametrize('fixed_parm', [(None, None),
                                         ('lambda', 3e-7),
-                                        ('gamma', 0.5),
+                                        ('gamma', 0.1),
                                         ('eta', 1e-4),
                                         ('threshold', 0.0046),
                                         ('width', 0.0046)])
diff --git a/tests/test_param_recovery.py b/tests/test_param_recovery.py
index 90682f8..3be2091 100644
--- a/tests/test_param_recovery.py
+++ b/tests/test_param_recovery.py
@@ -115,7 +115,10 @@ def test_parameter_recovery_fixed_params(fixed_param):
     # we fix it to a slightly off value, so we can check if stays fixed
     options['fixed_parameters'][fixed_param] = sim_params[fixed_param]
 
-    res = psignifit(data, **options)
+    with warnings.catch_warnings():
+        # ignore warning about gamma behind unusual
+        warnings.simplefilter("ignore")
+        res = psignifit(data, **options)
     # check fixed params are not touched
     assert res.parameter_estimate_MAP[fixed_param] == sim_params[fixed_param]