adaptivetesting 1.1.1__tar.gz → 1.1.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. adaptivetesting-1.1.3/.github/CONTRIBUTING.md +11 -0
  2. adaptivetesting-1.1.3/.readthedocs.yml +22 -0
  3. adaptivetesting-1.1.3/CHANGELOG.md +4 -0
  4. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/PKG-INFO +43 -20
  5. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/README.md +42 -19
  6. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/math/estimators/__functions/__estimators.py +49 -21
  7. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/math/estimators/__test_information.py +8 -18
  8. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/models/__item_pool.py +48 -24
  9. adaptivetesting-1.1.3/adaptivetesting/models/__test_item.py +33 -0
  10. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/tests/test_load_test_items.py +65 -1
  11. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/tests/test_test_assembler.py +1 -1
  12. adaptivetesting-1.1.3/docs/requirements.txt +0 -0
  13. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/docs/source/conf.py +5 -3
  14. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/examples/testing.py +7 -4
  15. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/pyproject.toml +6 -7
  16. adaptivetesting-1.1.3/requirements.txt +0 -0
  17. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/uv.lock +2268 -2195
  18. adaptivetesting-1.1.1/BREAKING_CHANGES.md +0 -65
  19. adaptivetesting-1.1.1/adaptivetesting/models/__test_item.py +0 -25
  20. adaptivetesting-1.1.1/requirements.txt +0 -612
  21. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/.github/workflows/publish.yml +0 -0
  22. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/.github/workflows/python-test.yml +0 -0
  23. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/.gitignore +0 -0
  24. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/.gitlab-ci.yml +0 -0
  25. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/.python-version +0 -0
  26. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/.vscode/extensions.json +0 -0
  27. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/.vscode/settings.json +0 -0
  28. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/LICENSE +0 -0
  29. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/__init__.py +0 -0
  30. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/data/__csv_context.py +0 -0
  31. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/data/__init__.py +0 -0
  32. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/data/__pickle_context.py +0 -0
  33. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/implementations/__default_implementation.py +0 -0
  34. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/implementations/__init__.py +0 -0
  35. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/implementations/__pre_test.py +0 -0
  36. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/implementations/__semi_implementation.py +0 -0
  37. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/implementations/__test_assembler.py +0 -0
  38. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/math/__gen_response_pattern.py +0 -0
  39. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/math/__init__.py +0 -0
  40. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/math/estimators/__bayes_modal_estimation.py +0 -0
  41. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/math/estimators/__expect_a_posteriori.py +0 -0
  42. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/math/estimators/__functions/__bayes.py +0 -0
  43. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/math/estimators/__init__.py +0 -0
  44. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/math/estimators/__ml_estimation.py +0 -0
  45. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/math/estimators/__prior.py +0 -0
  46. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/math/item_selection/__init__.py +0 -0
  47. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/math/item_selection/__maximum_information_criterion.py +0 -0
  48. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/math/item_selection/__urrys_rule.py +0 -0
  49. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/models/__adaptive_test.py +0 -0
  50. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/models/__algorithm_exception.py +0 -0
  51. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/models/__init__.py +0 -0
  52. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/models/__item_selection_exception.py +0 -0
  53. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/models/__misc.py +0 -0
  54. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/models/__test_result.py +0 -0
  55. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/services/__estimator_interface.py +0 -0
  56. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/services/__init__.py +0 -0
  57. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/services/__item_selection_protocol.py +0 -0
  58. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/services/__test_results_interface.py +0 -0
  59. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/simulation/__init__.py +0 -0
  60. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/simulation/__simulation.py +0 -0
  61. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/tests/__init__.py +0 -0
  62. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/tests/test_adaptive_test.py +0 -0
  63. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/tests/test_bayes_modal.py +0 -0
  64. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/tests/test_eap.py +0 -0
  65. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/tests/test_generate_response_pattern.py +0 -0
  66. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/tests/test_information_test.py +0 -0
  67. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/tests/test_item_selection.py +0 -0
  68. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/tests/test_mle.py +0 -0
  69. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/tests/test_pretest.py +0 -0
  70. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/tests/test_simulation.py +0 -0
  71. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/adaptivetesting/tests/test_standard_error.py +0 -0
  72. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/docs/Makefile +0 -0
  73. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/docs/data.md +0 -0
  74. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/docs/implementations.md +0 -0
  75. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/docs/index.md +0 -0
  76. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/docs/make.bat +0 -0
  77. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/docs/math.md +0 -0
  78. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/docs/models.md +0 -0
  79. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/docs/readme.md +0 -0
  80. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/docs/services.md +0 -0
  81. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/docs/simulation.md +0 -0
  82. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/docs/source/_static/logo.svg +0 -0
  83. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/docs/source/_static/main.css +0 -0
  84. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/docs/source/data.rst +0 -0
  85. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/docs/source/implementations.rst +0 -0
  86. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/docs/source/index.rst +0 -0
  87. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/docs/source/math.rst +0 -0
  88. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/docs/source/models.rst +0 -0
  89. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/docs/source/services.rst +0 -0
  90. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/docs/source/simulation.rst +0 -0
  91. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/docs/source/tests.rst +0 -0
  92. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/docs/tests.md +0 -0
  93. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/examples/.gitignore +0 -0
  94. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/examples/simulation.py +0 -0
  95. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/images/default.svg +0 -0
  96. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/images/semi-adaptive.svg +0 -0
  97. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/mypy.ini +0 -0
  98. {adaptivetesting-1.1.1 → adaptivetesting-1.1.3}/setup.cfg +0 -0
@@ -0,0 +1,11 @@
1
+ # Contributing Guidelines
2
+
3
+ Thank you for your interest in contributing!
4
+
5
+ - All pull requests **must** be opened against the `dev` branch.
6
+ - Ensure your code passes all test workflows:
7
+ - `mypy` (type checking)
8
+ - `flake8` (linting)
9
+ - Unit tests
10
+
11
+ Pull requests that meet these requirements will be considered for merging.
@@ -0,0 +1,22 @@
1
+ # Read the Docs configuration file
2
+ # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
3
+
4
+ # Required
5
+ version: 2
6
+
7
+ # Set the OS, Python version, and other tools you might need
8
+ build:
9
+ os: ubuntu-24.04
10
+ tools:
11
+ python: "3.13"
12
+
13
+ # Build documentation in the "docs/" directory with Sphinx
14
+ sphinx:
15
+ configuration: docs/source/conf.py
16
+
17
+ python:
18
+ install:
19
+ - method: pip
20
+ path: .
21
+ - requirements: docs/requirements.txt
22
+
@@ -0,0 +1,4 @@
1
+ # Version 1.1.2
2
+
3
+ - The `ItemPool` class now correctly honors the `id` field of items. (#30, #31)
4
+ - Examples and the readme file have been updated accordingly.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: adaptivetesting
3
- Version: 1.1.1
3
+ Version: 1.1.3
4
4
  Summary: adaptivetesting is a Python package that can be used to simulate and evaluate custom CAT scenarios as well as implement them in real-world testing scenarios from a single codebase
5
5
  Project-URL: Homepage, https://github.com/condecon/adaptivetesting
6
6
  Project-URL: Documentation, https://github.com/condecon/adaptivetesting/tree/main/docs
@@ -29,7 +29,7 @@ Description-Content-Type: text/markdown
29
29
  - **Bayesian Methods**: Built-in support for Bayesian ability estimation with customizable priors
30
30
  - **Flexible Architecture**: Object-oriented design with abstract classes for easy extension
31
31
  - **Item Response Theory**: Full support for 1PL, 2PL, 3PL, and 4PL models
32
- - **Multiple Estimators**:
32
+ - **Multiple Estimators**:
33
33
  - Maximum Likelihood Estimation (MLE)
34
34
  - Bayesian Modal Estimation (BM)
35
35
  - Expected A Posteriori (EAP)
@@ -131,25 +131,31 @@ adaptive_test = TestAssembler(
131
131
  ### Real-world Testing (Non-simulation) with PsychoPy
132
132
 
133
133
  ```python
134
+ # setup item pool
135
+ # the item pool is retrieved from the PREVIC
136
+ # https://github.com/manuelbohn/previc/tree/main/saves
137
+ import pandas as pd
138
+ from adaptivetesting.models import ItemPool
134
139
  from psychopy import visual, event
135
140
  from psychopy.hardware import keyboard
136
141
  from adaptivetesting.implementations import TestAssembler
137
142
  from adaptivetesting.models import AdaptiveTest, ItemPool, TestItem
138
143
  from adaptivetesting.data import CSVContext
139
- from adaptivetesting.math.estimators import BayesModal, CustomPrior
144
+ from adaptivetesting.math.estimators import ExpectedAPosteriori, CustomPrior
140
145
  from adaptivetesting.math.item_selection import maximum_information_criterion
141
146
  from scipy.stats import t
142
147
  import pandas as pd
143
148
 
149
+ previc_item_pool = pd.read_csv("item_pool.csv")
150
+ # add item column
151
+ previc_item_pool["id"] = list(range(1, 90))
152
+ previc_item_pool.head()
144
153
 
145
- # Create item pool from DataFrame
146
- items_data = pd.DataFrame({
147
- "a": [1.32, 1.07, 0.84, 1.19, 0.95], # discrimination
148
- "b": [-0.63, 0.18, -0.84, 0.41, -0.25], # difficulty
149
- "c": [0.17, 0.10, 0.19, 0.15, 0.12], # guessing
150
- "d": [0.87, 0.93, 1.0, 0.89, 0.94] # upper asymptote
151
- })
152
- item_pool = ItemPool.load_from_dataframe(items_data)
154
+
155
+ item_pool = ItemPool.load_from_list(
156
+ b=previc_item_pool["Difficulty"],
157
+ ids=previc_item_pool["id"]
158
+ )
153
159
 
154
160
  # Create adaptive test
155
161
  adaptive_test: AdaptiveTest = TestAssembler(
@@ -179,12 +185,12 @@ win = visual.Window([800, 600],
179
185
  # init keyboard
180
186
  keyboard.Keyboard()
181
187
 
188
+ ## FIX THIS
182
189
 
183
190
  # define function to get user input
184
191
  def get_response(item: TestItem) -> int:
185
- # get index
186
- item_difficulty: float = item.b
187
- stimuli: str = [item for item in item_pool.test_items if item["Difficulty"] == item_difficulty][0]["word"]
192
+ # select corresponding word from item pool data frame
193
+ stimuli: str = previc_item_pool[previc_item_pool["id"] == item.id]["word"].values[0]
188
194
 
189
195
  # create text box and display stimulus
190
196
  text_box = visual.TextBox2(win=win,
@@ -309,17 +315,18 @@ adaptive_test = TestAssembler(
309
315
  Full documentation is available in the `docs/` directory:
310
316
 
311
317
  - [API Reference](docs/readme.md)
312
- - [Models Module](docs/adaptivetesting.models.md)
313
- - [Math Module](docs/adaptivetesting.math.md)
314
- - [Implementation Examples](docs/adaptivetesting.implementations.md)
315
- - [Simulation Guide](docs/adaptivetesting.simulation.md)
318
+ - [Models Module](docs/models.md)
319
+ - [Math Module](docs/math.md)
320
+ - [Implementation Examples](docs/implementations.md)
321
+ - [Simulation Guide](docs/simulation.md)
316
322
 
317
323
  ## Testing
318
324
 
319
325
  The package includes comprehensive tests. Run them using:
320
326
 
321
327
  ```bash
322
- python -m pytest adaptivetesting/tests/
328
+ uv sync
329
+ uv run python -m unittest
323
330
  ```
324
331
 
325
332
  ## Contributing
@@ -327,7 +334,7 @@ python -m pytest adaptivetesting/tests/
327
334
  We welcome contributions! Please see our [GitHub repository](https://github.com/condecon/adaptivetesting) for:
328
335
 
329
336
  - Issue tracking
330
- - Feature requests
337
+ - Feature requests
331
338
  - Pull request guidelines
332
339
  - Development setup
333
340
 
@@ -343,6 +350,22 @@ This package is designed for researchers and practitioners in:
343
350
 
344
351
  The package facilitates the transition from research simulation to real-world testing applications without requiring major code modifications.
345
352
 
353
+ ## Citation
354
+ If you use this package for your academic work, please provide the following reference:
355
+ Engicht, J., Bee, R. M., & Koch, T. (2025). Customizable Bayesian Adaptive Testing with Python – The adaptivetesting Package. Open Science Framework. https://doi.org/10.31219/osf.io/d2xge_v1
356
+
357
+ ```
358
+ @online{engichtCustomizableBayesianAdaptive2025,
359
+ title = {Customizable {{Bayesian Adaptive Testing}} with {{Python}} – {{The}} Adaptivetesting {{Package}}},
360
+ author = {Engicht, Jonas and Bee, R. Maximilian and Koch, Tobias},
361
+ date = {2025-08-06},
362
+ eprinttype = {Open Science Framework},
363
+ doi = {10.31219/osf.io/d2xge_v1},
364
+ url = {https://osf.io/d2xge_v1},
365
+ pubstate = {prepublished}
366
+ }
367
+ ```
368
+
346
369
  ## License
347
370
 
348
371
  This project is licensed under the terms specified in the [LICENSE](LICENSE) file.
@@ -11,7 +11,7 @@
11
11
  - **Bayesian Methods**: Built-in support for Bayesian ability estimation with customizable priors
12
12
  - **Flexible Architecture**: Object-oriented design with abstract classes for easy extension
13
13
  - **Item Response Theory**: Full support for 1PL, 2PL, 3PL, and 4PL models
14
- - **Multiple Estimators**:
14
+ - **Multiple Estimators**:
15
15
  - Maximum Likelihood Estimation (MLE)
16
16
  - Bayesian Modal Estimation (BM)
17
17
  - Expected A Posteriori (EAP)
@@ -113,25 +113,31 @@ adaptive_test = TestAssembler(
113
113
  ### Real-world Testing (Non-simulation) with PsychoPy
114
114
 
115
115
  ```python
116
+ # setup item pool
117
+ # the item pool is retrieved from the PREVIC
118
+ # https://github.com/manuelbohn/previc/tree/main/saves
119
+ import pandas as pd
120
+ from adaptivetesting.models import ItemPool
116
121
  from psychopy import visual, event
117
122
  from psychopy.hardware import keyboard
118
123
  from adaptivetesting.implementations import TestAssembler
119
124
  from adaptivetesting.models import AdaptiveTest, ItemPool, TestItem
120
125
  from adaptivetesting.data import CSVContext
121
- from adaptivetesting.math.estimators import BayesModal, CustomPrior
126
+ from adaptivetesting.math.estimators import ExpectedAPosteriori, CustomPrior
122
127
  from adaptivetesting.math.item_selection import maximum_information_criterion
123
128
  from scipy.stats import t
124
129
  import pandas as pd
125
130
 
131
+ previc_item_pool = pd.read_csv("item_pool.csv")
132
+ # add item column
133
+ previc_item_pool["id"] = list(range(1, 90))
134
+ previc_item_pool.head()
126
135
 
127
- # Create item pool from DataFrame
128
- items_data = pd.DataFrame({
129
- "a": [1.32, 1.07, 0.84, 1.19, 0.95], # discrimination
130
- "b": [-0.63, 0.18, -0.84, 0.41, -0.25], # difficulty
131
- "c": [0.17, 0.10, 0.19, 0.15, 0.12], # guessing
132
- "d": [0.87, 0.93, 1.0, 0.89, 0.94] # upper asymptote
133
- })
134
- item_pool = ItemPool.load_from_dataframe(items_data)
136
+
137
+ item_pool = ItemPool.load_from_list(
138
+ b=previc_item_pool["Difficulty"],
139
+ ids=previc_item_pool["id"]
140
+ )
135
141
 
136
142
  # Create adaptive test
137
143
  adaptive_test: AdaptiveTest = TestAssembler(
@@ -161,12 +167,12 @@ win = visual.Window([800, 600],
161
167
  # init keyboard
162
168
  keyboard.Keyboard()
163
169
 
170
+ ## FIX THIS
164
171
 
165
172
  # define function to get user input
166
173
  def get_response(item: TestItem) -> int:
167
- # get index
168
- item_difficulty: float = item.b
169
- stimuli: str = [item for item in item_pool.test_items if item["Difficulty"] == item_difficulty][0]["word"]
174
+ # select corresponding word from item pool data frame
175
+ stimuli: str = previc_item_pool[previc_item_pool["id"] == item.id]["word"].values[0]
170
176
 
171
177
  # create text box and display stimulus
172
178
  text_box = visual.TextBox2(win=win,
@@ -291,17 +297,18 @@ adaptive_test = TestAssembler(
291
297
  Full documentation is available in the `docs/` directory:
292
298
 
293
299
  - [API Reference](docs/readme.md)
294
- - [Models Module](docs/adaptivetesting.models.md)
295
- - [Math Module](docs/adaptivetesting.math.md)
296
- - [Implementation Examples](docs/adaptivetesting.implementations.md)
297
- - [Simulation Guide](docs/adaptivetesting.simulation.md)
300
+ - [Models Module](docs/models.md)
301
+ - [Math Module](docs/math.md)
302
+ - [Implementation Examples](docs/implementations.md)
303
+ - [Simulation Guide](docs/simulation.md)
298
304
 
299
305
  ## Testing
300
306
 
301
307
  The package includes comprehensive tests. Run them using:
302
308
 
303
309
  ```bash
304
- python -m pytest adaptivetesting/tests/
310
+ uv sync
311
+ uv run python -m unittest
305
312
  ```
306
313
 
307
314
  ## Contributing
@@ -309,7 +316,7 @@ python -m pytest adaptivetesting/tests/
309
316
  We welcome contributions! Please see our [GitHub repository](https://github.com/condecon/adaptivetesting) for:
310
317
 
311
318
  - Issue tracking
312
- - Feature requests
319
+ - Feature requests
313
320
  - Pull request guidelines
314
321
  - Development setup
315
322
 
@@ -325,6 +332,22 @@ This package is designed for researchers and practitioners in:
325
332
 
326
333
  The package facilitates the transition from research simulation to real-world testing applications without requiring major code modifications.
327
334
 
335
+ ## Citation
336
+ If you use this package for your academic work, please provide the following reference:
337
+ Engicht, J., Bee, R. M., & Koch, T. (2025). Customizable Bayesian Adaptive Testing with Python – The adaptivetesting Package. Open Science Framework. https://doi.org/10.31219/osf.io/d2xge_v1
338
+
339
+ ```
340
+ @online{engichtCustomizableBayesianAdaptive2025,
341
+ title = {Customizable {{Bayesian Adaptive Testing}} with {{Python}} – {{The}} Adaptivetesting {{Package}}},
342
+ author = {Engicht, Jonas and Bee, R. Maximilian and Koch, Tobias},
343
+ date = {2025-08-06},
344
+ eprinttype = {Open Science Framework},
345
+ doi = {10.31219/osf.io/d2xge_v1},
346
+ url = {https://osf.io/d2xge_v1},
347
+ pubstate = {prepublished}
348
+ }
349
+ ```
350
+
328
351
  ## License
329
352
 
330
353
  This project is licensed under the terms specified in the [LICENSE](LICENSE) file.
@@ -12,22 +12,50 @@ def probability_y1(mu: np.ndarray,
12
12
 
13
13
  Args:
14
14
  mu (np.ndarray): latent ability level
15
-
16
15
  a (np.ndarray): item discrimination parameter
17
-
18
16
  b (np.ndarray): item difficulty parameter
19
-
20
17
  c (np.ndarray): pseudo guessing parameter
21
-
22
18
  d (np.ndarray): inattention parameter
23
19
 
24
20
  Returns:
25
21
  np.ndarray: probability of getting the item correct
26
22
  """
23
+ # Compute the exponent safely
24
+ z = a * (mu - b)
25
+
26
+ # Use log-sum-exp trick for numerical stability
27
+ # For large positive z: exp(z)/(1+exp(z)) ≈ 1
28
+ # For large negative z: exp(z)/(1+exp(z)) ≈ exp(z)
29
+
30
+ # Clip z to prevent overflow in exp()
31
+ z_clipped = np.clip(z, -500, 500) # exp(-500) and exp(500) are safe
32
+
33
+ # Compute the logistic function safely
34
+ # Use different formulas based on the sign of z for stability
35
+ result = np.empty_like(z_clipped)
36
+
37
+ # For large positive values, use alternative formula to avoid overflow
38
+ mask_positive = z_clipped > 20 # exp(20) is large but manageable
39
+ mask_negative = z_clipped < -20 # exp(-20) is very small
40
+ mask_medium = ~(mask_positive | mask_negative)
41
+
42
+ # Large positive z: 1/(1+exp(-z)) is more stable
43
+ result[mask_positive] = 1.0 / (1.0 + np.exp(-z_clipped[mask_positive]))
44
+
45
+ # Large negative z: exp(z)/(1+exp(z)) is stable
46
+ exp_z_neg = np.exp(z_clipped[mask_negative])
47
+ result[mask_negative] = exp_z_neg / (1.0 + exp_z_neg)
48
+
49
+ # Medium values: use standard formula
50
+ exp_z_med = np.exp(z_clipped[mask_medium])
51
+ result[mask_medium] = exp_z_med / (1.0 + exp_z_med)
52
+
53
+ # Apply the 4PL transformation
54
+ value = c + (d - c) * result
55
+
56
+ # Ensure probabilities are within valid range [c, d]
57
+ value = np.clip(value, c, d)
27
58
 
28
- value = c + (d - c) * (np.exp(a * (mu - b))) / \
29
- (1 + np.exp(a * (mu - b)))
30
-
31
59
  return np.squeeze(value)
32
60
 
33
61
 
@@ -40,13 +68,13 @@ def probability_y0(mu: np.ndarray,
40
68
 
41
69
  Args:
42
70
  mu (np.ndarray): latent ability level
43
-
71
+
44
72
  a (np.ndarray): item discrimination parameter
45
-
73
+
46
74
  b (np.ndarray): item difficulty parameter
47
-
75
+
48
76
  c (np.ndarray): pseudo guessing parameter
49
-
77
+
50
78
  d (np.ndarray): inattention parameter
51
79
 
52
80
  Returns:
@@ -68,13 +96,13 @@ def likelihood(mu: np.ndarray,
68
96
 
69
97
  Args:
70
98
  mu (np.ndarray): ability level
71
-
99
+
72
100
  a (np.ndarray): item discrimination parameter
73
-
101
+
74
102
  b (np.ndarray): item difficulty parameter
75
-
103
+
76
104
  c (np.ndarray): pseudo guessing parameter
77
-
105
+
78
106
  d (np.ndarray): inattention parameter
79
107
 
80
108
  Returns:
@@ -88,7 +116,7 @@ def likelihood(mu: np.ndarray,
88
116
 
89
117
  terms = (probability_y1(mu, a, b, c, d)**response_pattern) * \
90
118
  (probability_y0(mu, a, b, c, d) ** (1 - response_pattern))
91
-
119
+
92
120
  return -np.prod(terms)
93
121
 
94
122
 
@@ -100,14 +128,14 @@ def maximize_likelihood_function(a: np.ndarray,
100
128
  border: tuple[float, float] = (-10, 10)) -> float:
101
129
  """Find the ability value that maximizes the likelihood function.
102
130
  This function uses the minimize_scalar function from scipy and the "bounded" method.
103
-
131
+
104
132
  Args:
105
133
  a (np.ndarray): item discrimination parameter
106
-
134
+
107
135
  b (np.ndarray): item difficulty parameter
108
-
136
+
109
137
  c (np.ndarray): pseudo guessing parameter
110
-
138
+
111
139
  d (np.ndarray): inattention parameter
112
140
 
113
141
  response_pattern (np.ndarray): response pattern of the item
@@ -129,7 +157,7 @@ def maximize_likelihood_function(a: np.ndarray,
129
157
  "Response pattern is invalid. It consists of only one type of response.")
130
158
  raise AlgorithmException(
131
159
  "Response pattern is invalid. It consists of only one type of response.")
132
-
160
+
133
161
  result: OptimizeResult = minimize_scalar(likelihood, args=(a, b, c, d, response_pattern),
134
162
  bounds=border, method='bounded') # type: ignore
135
163
 
@@ -13,27 +13,17 @@ def item_information_function(
13
13
  c: np.ndarray,
14
14
  d: np.ndarray
15
15
  ) -> np.ndarray:
16
- """Calculate the information of a test item given the currently
17
- estimated ability `mu`.
18
-
19
- Args:
20
- mu (np.ndarray): currently estimated ability
21
- a (np.ndarray): _description_
22
- b (np.ndarray): _description_
23
- c (np.ndarray): _description_
24
- d (np.ndarray): _description_
25
-
26
- Returns:
27
- np.ndarray: _description_
28
- """
29
16
  p_y1 = probability_y1(mu, a, b, c, d)
30
17
 
18
+ # Clip probabilities
19
+ p_y1_clipped = np.clip(p_y1, 1e-10, 1 - 1e-10)
20
+
31
21
  def p_y1_grad(x: np.ndarray) -> np.ndarray:
32
- # Central finite difference for gradient
33
- h = 1e-5
22
+ # Use a more robust finite difference scheme
23
+ h = np.maximum(1e-8, 1e-8 * np.abs(x)) # Adaptive step size
34
24
  return (probability_y1(x + h, a, b, c, d) - probability_y1(x - h, a, b, c, d)) / (2 * h)
35
-
36
- product = (p_y1_grad(mu) ** 2) / (p_y1 * (1 - p_y1))
25
+
26
+ product = (p_y1_grad(mu) ** 2) / (p_y1_clipped * (1 - p_y1_clipped))
37
27
  information = np.sum(product)
38
28
  return information
39
29
 
@@ -59,7 +49,7 @@ def prior_information_function(prior: Prior,
59
49
  (score_values ** 2) * prior.pdf(x_vals),
60
50
  x_vals
61
51
  )
62
-
52
+
63
53
  return information
64
54
 
65
55
 
@@ -37,7 +37,7 @@ class ItemPool:
37
37
  return selected_item, simulated_response
38
38
  else:
39
39
  return selected_item
40
-
40
+
41
41
  def get_item_by_item(self, item: TestItem) -> Tuple[TestItem, int] | TestItem:
42
42
  """Returns item and if defined the simulated response.
43
43
 
@@ -94,24 +94,27 @@ class ItemPool:
94
94
  a: List[float] | None = None,
95
95
  c: List[float] | None = None,
96
96
  d: List[float] | None = None,
97
- simulated_responses: List[int] | None = None) -> "ItemPool":
97
+ simulated_responses: List[int] | None = None,
98
+ ids: List[int] | None = None) -> "ItemPool":
98
99
  """
99
100
  Creates test items from a list of floats.
100
101
 
101
102
  Args:
102
103
  a (List[float]): discrimination parameter
103
-
104
+
104
105
  b (List[float]): difficulty parameter
105
-
106
+
106
107
  c (List[float]): guessing parameter
107
-
108
+
108
109
  d (List[float]): slipping parameter
109
-
110
+
110
111
  simulated_responses (List[int]): simulated responses
111
112
 
113
+ ids (List[int]): item IDs
114
+
112
115
  Returns:
113
116
  List[TestItem]: item pool
114
-
117
+
115
118
  """
116
119
  items: List[TestItem] = []
117
120
 
@@ -126,19 +129,25 @@ class ItemPool:
126
129
  raise ValueError("Length of a and b has to be the same.")
127
130
  for i, discrimination in enumerate(a):
128
131
  items[i].a = discrimination
129
-
132
+
130
133
  if c is not None:
131
134
  if len(c) != len(b):
132
135
  raise ValueError("Length of c and b has to be the same.")
133
136
  for i, guessing in enumerate(c):
134
137
  items[i].c = guessing
135
-
138
+
136
139
  if d is not None:
137
140
  if len(d) != len(b):
138
141
  raise ValueError("Length of d and b has to be the same.")
139
142
  for i, slipping in enumerate(d):
140
143
  items[i].d = slipping
141
144
 
145
+ if ids is not None:
146
+ if len(ids) != len(b):
147
+ raise ValueError("Length of ids and b has to be the same.")
148
+ for i, id_ in enumerate(ids):
149
+ items[i].id = id_
150
+
142
151
  item_pool = ItemPool(items)
143
152
  item_pool.simulated_responses = simulated_responses
144
153
 
@@ -146,10 +155,11 @@ class ItemPool:
146
155
 
147
156
  @staticmethod
148
157
  def load_from_dict(source: dict[str, List[float]],
149
- simulated_responses: List[int] | None = None) -> "ItemPool":
158
+ simulated_responses: List[int] | None = None,
159
+ ids: List[int] | None = None) -> "ItemPool":
150
160
  """Creates test items from a dictionary.
151
161
  The dictionary has to have the following keys:
152
-
162
+
153
163
  - a
154
164
  - b
155
165
  - c
@@ -158,6 +168,8 @@ class ItemPool:
158
168
 
159
169
  Args:
160
170
  source (dict[str, List[float]]): item pool dictionary
171
+ simulated_responses (List[int]): simulated responses
172
+ ids (List[int]): item IDs
161
173
 
162
174
  Returns:
163
175
  List[TestItem]: item pool
@@ -170,13 +182,13 @@ class ItemPool:
170
182
  # check none
171
183
  if a is None:
172
184
  raise ValueError("a cannot be None")
173
-
185
+
174
186
  if b is None:
175
187
  raise ValueError("b cannot be None")
176
-
188
+
177
189
  if c is None:
178
190
  raise ValueError("c cannot be None")
179
-
191
+
180
192
  if d is None:
181
193
  raise ValueError("d cannot be None")
182
194
 
@@ -184,6 +196,10 @@ class ItemPool:
184
196
  if not (len(a) == len(b) == len(c) == len(d)):
185
197
  raise ValueError("All lists in the source dictionary must have the same length")
186
198
 
199
+ if ids is not None:
200
+ if len(ids) != len(b):
201
+ raise ValueError("Length of ids and b has to be the same.")
202
+
187
203
  n_items = len(b)
188
204
  items: List[TestItem] = []
189
205
  for i in range(n_items):
@@ -193,12 +209,15 @@ class ItemPool:
193
209
  item.c = c[i]
194
210
  item.d = d[i]
195
211
 
212
+ if ids is not None:
213
+ item.id = ids[i]
214
+
196
215
  items.append(item)
197
216
 
198
217
  item_pool = ItemPool(items, simulated_responses)
199
-
218
+
200
219
  return item_pool
201
-
220
+
202
221
  @staticmethod
203
222
  def load_from_dataframe(source: DataFrame) -> "ItemPool":
204
223
  """Creates item pool from a pandas DataFrame.
@@ -206,7 +225,7 @@ class ItemPool:
206
225
  Each column has to contain float values.
207
226
  A `simulated_responses` (int values) column can be added to
208
227
  the DataFrame to provide simulated responses.
209
-
228
+
210
229
 
211
230
  Args:
212
231
  source (DataFrame): _description_
@@ -218,28 +237,33 @@ class ItemPool:
218
237
  # check if columns are present
219
238
  if "a" not in source.columns:
220
239
  raise ValueError("Column 'a' not found.")
221
-
240
+
222
241
  if "b" not in source.columns:
223
242
  raise ValueError("Column 'b' not found.")
224
-
243
+
225
244
  if "c" not in source.columns:
226
245
  raise ValueError("Column 'c' not found.")
227
-
246
+
228
247
  if "d" not in source.columns:
229
248
  raise ValueError("Column 'd' not found.")
230
-
249
+
231
250
  # get values
232
251
  a: List[float] = source["a"].values.tolist() # type: ignore
233
252
  b: List[float] = source["b"].values.tolist() # type: ignore
234
253
  c: List[float] = source["c"].values.tolist() # type: ignore
235
254
  d: List[float] = source["d"].values.tolist() # type: ignore
236
255
 
256
+ if "ids" in source.columns:
257
+ ids: List[int] | None = source["ids"].values.tolist() # type: ignore
258
+ else:
259
+ ids = None
260
+
237
261
  # create item pool
238
- item_pool = ItemPool.load_from_list(a=a, b=b, c=c, d=d)
239
-
262
+ item_pool = ItemPool.load_from_list(a=a, b=b, c=c, d=d, ids=ids)
263
+
240
264
  # check if simulated responses are present
241
265
  if "simulated_responses" in source.columns:
242
266
  simulated_responses: List[int] = source["simulated_responses"].values.tolist() # type: ignore
243
267
  item_pool.simulated_responses = simulated_responses
244
-
268
+
245
269
  return item_pool
@@ -0,0 +1,33 @@
1
+ class TestItem:
2
+ def __init__(self):
3
+ """Representation of a test item in the item pool.
4
+ The format is equal to the implementation in catR.
5
+
6
+ Properties:
7
+ - id (int | None): item ID
8
+ - a (float): discrimination parameter
9
+ - b (float): difficulty parameter
10
+ - c (float): guessing parameter
11
+ - d (float): slipping parameter / upper asymptote
12
+
13
+ """
14
+ self.id: int | None = None
15
+ self.a: float = 1
16
+ self.b: float = float("nan")
17
+ self.c: float = 0
18
+ self.d: float = 1
19
+
20
+ def as_dict(self, with_id: bool = False) -> dict[str, float | int | None]:
21
+ """Returns the item as a dictionary"""
22
+
23
+ item_dict: dict[str, float | int | None] = {
24
+ "a": self.a,
25
+ "b": self.b,
26
+ "c": self.c,
27
+ "d": self.d
28
+ }
29
+
30
+ if with_id and self.id is not None:
31
+ item_dict["id"] = self.id
32
+
33
+ return item_dict