adaptivetesting 1.0.0__py3-none-any.whl

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 (38) hide show
  1. adaptivetesting/__init__.py +7 -0
  2. adaptivetesting/data/__init__.py +2 -0
  3. adaptivetesting/data/__pickle_context.py +51 -0
  4. adaptivetesting/data/__sqlite_context.py +76 -0
  5. adaptivetesting/implementations/__default_implementation.py +72 -0
  6. adaptivetesting/implementations/__init__.py +3 -0
  7. adaptivetesting/implementations/__pre_test.py +83 -0
  8. adaptivetesting/implementations/__semi_implementation.py +134 -0
  9. adaptivetesting/math/__init__.py +2 -0
  10. adaptivetesting/math/__standard_error.py +59 -0
  11. adaptivetesting/math/__test_information.py +43 -0
  12. adaptivetesting/models/__adaptive_test.py +183 -0
  13. adaptivetesting/models/__algorithm_exception.py +3 -0
  14. adaptivetesting/models/__init__.py +7 -0
  15. adaptivetesting/models/__item_pool.py +246 -0
  16. adaptivetesting/models/__item_selection_exception.py +3 -0
  17. adaptivetesting/models/__test_item.py +29 -0
  18. adaptivetesting/models/__test_result.py +23 -0
  19. adaptivetesting/services/__estimator_interface.py +40 -0
  20. adaptivetesting/services/__init__.py +2 -0
  21. adaptivetesting/services/__test_results_interface.py +27 -0
  22. adaptivetesting/simulation/__init__.py +1 -0
  23. adaptivetesting/simulation/__simulation.py +75 -0
  24. adaptivetesting/tests/__init__.py +0 -0
  25. adaptivetesting/tests/test_adaptive_test.py +67 -0
  26. adaptivetesting/tests/test_bayes_modal.py +31 -0
  27. adaptivetesting/tests/test_code_examples.py +22 -0
  28. adaptivetesting/tests/test_information_test.py +38 -0
  29. adaptivetesting/tests/test_load_test_items.py +112 -0
  30. adaptivetesting/tests/test_mle.py +73 -0
  31. adaptivetesting/tests/test_pretest.py +39 -0
  32. adaptivetesting/tests/test_standard_error.py +57 -0
  33. adaptivetesting/tests/test_urrys_rule.py +28 -0
  34. adaptivetesting-1.0.0.dist-info/METADATA +108 -0
  35. adaptivetesting-1.0.0.dist-info/RECORD +38 -0
  36. adaptivetesting-1.0.0.dist-info/WHEEL +5 -0
  37. adaptivetesting-1.0.0.dist-info/licenses/LICENSE +372 -0
  38. adaptivetesting-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,183 @@
1
+ from typing import List
2
+ from .__test_item import TestItem
3
+ from ..math.item_selection.__urrys_rule import urrys_rule
4
+ from ..math.__standard_error import standard_error
5
+ import abc
6
+ from .__test_result import TestResult
7
+ from .__item_pool import ItemPool
8
+
9
+
10
+ class AdaptiveTest(abc.ABC):
11
+ def __init__(self, item_pool: ItemPool,
12
+ simulation_id: str,
13
+ participant_id: int,
14
+ true_ability_level: float,
15
+ initial_ability_level: float = 0,
16
+ simulation: bool = True,
17
+ DEBUG=False):
18
+ """Abstract implementation of an adaptive test.
19
+ All abstract methods have to be overridden
20
+ to create an instance of this class.
21
+
22
+ Abstract methods:
23
+ - estimate_ability_level
24
+
25
+ Args:
26
+ item_pool (ItemPool): item pool used for the test
27
+
28
+ simulation_id (str): simulation id
29
+
30
+ participant_id (int): participant id
31
+
32
+ true_ability_level (float): true ability level (must always be set)
33
+
34
+ initial_ability_level (float): initially assumed ability level
35
+
36
+ simulation (bool): will the test be simulated
37
+
38
+ DEBUG (bool): enables debug mode
39
+ """
40
+ self.true_ability_level: float = true_ability_level
41
+ self.simulation_id = simulation_id
42
+ self.participant_id: int = participant_id
43
+ # set start values
44
+ self.ability_level = initial_ability_level
45
+ self.answered_items: List[TestItem] = []
46
+ self.response_pattern: List[int] = []
47
+ self.test_results: List[TestResult] = []
48
+ # load items
49
+ self.item_pool = item_pool
50
+
51
+ # debug
52
+ self.DEBUG = DEBUG
53
+ self.simulation = simulation
54
+
55
+ def get_item_difficulties(self) -> List[float]:
56
+ """
57
+ Returns:
58
+ List[float]: difficulties of items in the item pool
59
+ """
60
+ return [item.b for item in self.item_pool.test_items]
61
+
62
+ def get_answered_items_difficulties(self) -> List[float]:
63
+ """
64
+ Returns:
65
+ List[float]: difficulties of answered items
66
+ """
67
+ return [item.b for item in self.answered_items]
68
+
69
+ def get_answered_items(self) -> List[TestItem]:
70
+ """
71
+ Returns:
72
+ List[TestItem]: answered items
73
+ """
74
+ return self.answered_items
75
+
76
+ def get_ability_se(self) -> float:
77
+ """
78
+ Calculate the current standard error
79
+ of the ability estimation.
80
+
81
+ Returns:
82
+ float: standard error of the ability estimation
83
+
84
+ """
85
+ answered_items= self.get_answered_items()
86
+ return standard_error(answered_items, self.ability_level)
87
+
88
+ def get_next_item(self) -> TestItem:
89
+ """Select next item using Urry's rule.
90
+
91
+ Returns:
92
+ TestItem: selected item
93
+ """
94
+ item = urrys_rule(self.item_pool.test_items, self.ability_level)
95
+ return item
96
+
97
+ @abc.abstractmethod
98
+ def estimate_ability_level(self) -> float:
99
+ """
100
+ Estimates ability level.
101
+ The method has to be implemented by subclasses.
102
+
103
+ Returns:
104
+ float: estimated ability level
105
+ """
106
+ pass
107
+
108
+ def get_response(self, item: TestItem) -> int:
109
+ """If the adaptive test is not used for simulation.
110
+ This method is used to get user feedback.
111
+
112
+ Args:
113
+ item (TestItem): test item shown to the participant
114
+
115
+ Return:
116
+ int: participant's response
117
+ """
118
+ raise NotImplementedError("This functionality is not implemented by default.")
119
+
120
+ def run_test_once(self):
121
+ """
122
+ Runs the test procedure once.
123
+ Saves the result to test_results of
124
+ the current instance.
125
+ """
126
+ # get item
127
+ item = self.get_next_item()
128
+ if item is not None:
129
+ if self.DEBUG:
130
+ print(f"Selected {item.b} for an ability level of {self.ability_level}.")
131
+
132
+ # check if simulation is running
133
+ response = None
134
+ if self.simulation is True:
135
+ response = self.item_pool.get_item_response(item)
136
+ else:
137
+ # not simulation
138
+ response = self.get_response(item)
139
+
140
+ if self.DEBUG:
141
+ print(f"Response: {response}")
142
+
143
+ # add response to response pattern
144
+ self.response_pattern.append(response)
145
+ # add item to answered items list
146
+ self.answered_items.append(item)
147
+
148
+ # estimate ability level
149
+ estimation = self.estimate_ability_level()
150
+
151
+ # update estimated ability level
152
+ self.ability_level = estimation
153
+ if self.DEBUG:
154
+ print(f"New estimation is {self.ability_level}")
155
+ # remove item from item pool
156
+ self.item_pool.delete_item(item)
157
+ if self.DEBUG:
158
+ print(f"Now, there are only {len(self.item_pool.test_items)} left in the item pool.")
159
+ # create result
160
+ result: TestResult = TestResult(
161
+ ability_estimation=estimation,
162
+ standard_error=self.get_ability_se(),
163
+ showed_item=item.b,
164
+ response=response,
165
+ test_id=self.simulation_id,
166
+ true_ability_level=self.true_ability_level
167
+ )
168
+
169
+ # add result to memory
170
+ self.test_results.append(result)
171
+
172
+ def check_se_criterion(self, value: float) -> bool:
173
+ if self.get_ability_se() <= value:
174
+ return True
175
+ else:
176
+ return False
177
+
178
+ def check_length_criterion(self, value: float) -> bool:
179
+ if len(self.answered_items) >= value:
180
+ return True
181
+ else:
182
+ return False
183
+
@@ -0,0 +1,3 @@
1
+ class AlgorithmException(Exception):
2
+ """Exception that is thrown when the estimation process did not find a maximum."""
3
+
@@ -0,0 +1,7 @@
1
+ from .__adaptive_test import AdaptiveTest
2
+ from .__algorithm_exception import AlgorithmException
3
+ from .__item_pool import ItemPool
4
+ from .__item_selection_exception import ItemSelectionException
5
+ from .__test_item import TestItem
6
+ from .__test_result import TestResult
7
+
@@ -0,0 +1,246 @@
1
+ from .__test_item import TestItem
2
+ from typing import List, Tuple
3
+ from pandas import DataFrame
4
+
5
+
6
+ class ItemPool:
7
+ def __init__(self,
8
+ test_items: List[TestItem],
9
+ simulated_responses: List[int] | None = None):
10
+ """An item pool has to be created for an adaptive test.
11
+ For that, a list of test items has to be provided. If the package is used
12
+ to simulate adaptive tests, simulated responses have to be supplied as well.
13
+ The responses are matched to the items internally.
14
+ Therefore, both have to be in the same order.
15
+
16
+ Args:
17
+ test_items (List[TestItem]): A list of test items. Necessary for any adaptive test.
18
+
19
+ simulated_responses (List[int]): A list of simulated responses. Required for CAT simulations.
20
+ """
21
+ self.test_items: List[TestItem] = test_items
22
+ self.simulated_responses: List[int] | None = simulated_responses
23
+
24
+ def get_item_by_index(self, index: int) -> Tuple[TestItem, int] | TestItem:
25
+ """Returns item and if defined the simulated response.
26
+
27
+ Args:
28
+ index (int): Index of the test item in the item pool to return.
29
+
30
+ Returns:
31
+ TestItem or (TestItem, Simulated Response)
32
+ """
33
+ selected_item = self.test_items[index]
34
+ if self.simulated_responses is not None:
35
+ simulated_response = self.simulated_responses[index]
36
+ return selected_item, simulated_response
37
+ else:
38
+ return selected_item
39
+
40
+ def get_item_by_item(self, item: TestItem) -> Tuple[TestItem, int] | TestItem:
41
+ """Returns item and if defined the simulated response.
42
+
43
+ Args:
44
+ item (TestItem): item to return.
45
+
46
+ Returns:
47
+ TestItem or (TestItem, Simulated Response)
48
+ """
49
+ index = self.test_items.index(item)
50
+ selected_item = self.test_items[index]
51
+ if self.simulated_responses is not None:
52
+ simulated_response = self.simulated_responses[index]
53
+ return selected_item, simulated_response
54
+ else:
55
+ return selected_item
56
+
57
+ def get_item_response(self, item: TestItem) -> int:
58
+ """
59
+ Gets the simulated response to an item if available.
60
+ A `ValueError` will be raised if a simulated response is not available.
61
+
62
+ Args:
63
+ item (TestItem): item to get the corresponding response
64
+
65
+ Returns:
66
+ (int): response (either `0` or `1`)
67
+ """
68
+ if self.simulated_responses is None:
69
+ raise ValueError("Simulated responses not provided")
70
+ else:
71
+ i, res = self.get_item_by_item(item) # type: ignore
72
+ return res
73
+
74
+ def delete_item(self, item: TestItem) -> None:
75
+ """Deletes item from item pool.
76
+ If simulated responses are defined, they will be deleted as well.
77
+
78
+ Args:
79
+ item (TestItem): The test item to delete.
80
+ """
81
+ # get index
82
+ index = self.test_items.index(item)
83
+ # remove item at index
84
+ self.test_items.pop(index)
85
+ # remove response at index
86
+ if self.simulated_responses is not None:
87
+ self.simulated_responses.pop(index)
88
+
89
+ # STATIC LOAD METHODS
90
+ @staticmethod
91
+ def load_from_list(
92
+ b: List[float],
93
+ a: List[float] | None = None,
94
+ c: List[float] | None = None,
95
+ d: List[float] | None = None,
96
+ simulated_responses: List[int] | None = None
97
+ ) -> "ItemPool":
98
+ """
99
+ Creates test items from a list of floats.
100
+
101
+ Args:
102
+ a (List[float]): discrimination parameter
103
+
104
+ b (List[float]): difficulty parameter
105
+
106
+ c (List[float]): guessing parameter
107
+
108
+ d (List[float]): slipping parameter
109
+
110
+ simulated_responses (List[int]): simulated responses
111
+
112
+ Returns:
113
+ List[TestItem]: item pool
114
+
115
+ """
116
+ items: List[TestItem] = []
117
+
118
+ for difficulty in b:
119
+ item = TestItem()
120
+ item.b = difficulty
121
+ items.append(item)
122
+
123
+ # check if a, b, c, d are the same length
124
+ if a is not None:
125
+ if len(a) != len(b):
126
+ raise ValueError("Length of a and b has to be the same.")
127
+ for i, discrimination in enumerate(a):
128
+ items[i].a = discrimination
129
+
130
+ if c is not None:
131
+ if len(c) != len(b):
132
+ raise ValueError("Length of c and b has to be the same.")
133
+ for i, guessing in enumerate(c):
134
+ items[i].c = guessing
135
+
136
+ if d is not None:
137
+ if len(d) != len(b):
138
+ raise ValueError("Length of d and b has to be the same.")
139
+ for i, slipping in enumerate(d):
140
+ items[i].d = slipping
141
+
142
+ item_pool = ItemPool(items)
143
+ item_pool.simulated_responses = simulated_responses
144
+
145
+ return item_pool
146
+
147
+ @staticmethod
148
+ def load_from_dict(source: dict[str, List[float]],
149
+ simulated_responses: List[int] | None = None) -> "ItemPool":
150
+ """Creates test items from a dictionary.
151
+ The dictionary has to have the following keys:
152
+
153
+ - a
154
+ - b
155
+ - c
156
+ - d
157
+ each containing a list of float.
158
+
159
+ Args:
160
+ source (dict[str, List[float]]): item pool dictionary
161
+
162
+ Returns:
163
+ List[TestItem]: item pool
164
+ """
165
+ a = source.get("a")
166
+ b = source.get("b")
167
+ c = source.get("c")
168
+ d = source.get("d")
169
+
170
+ # check none
171
+ if a is None:
172
+ raise ValueError("a cannot be None")
173
+
174
+ if b is None:
175
+ raise ValueError("b cannot be None")
176
+
177
+ if c is None:
178
+ raise ValueError("c cannot be None")
179
+
180
+ if d is None:
181
+ raise ValueError("d cannot be None")
182
+
183
+ # check if a, b, c, and d have the same length
184
+ if not (len(a) == len(b) == len(c) == len(d)):
185
+ raise ValueError("All lists in the source dictionary must have the same length")
186
+
187
+ n_items = len(b)
188
+ items: List[TestItem] = []
189
+ for i in range(n_items):
190
+ item = TestItem()
191
+ item.a = a[i]
192
+ item.b = b[i]
193
+ item.c = c[i]
194
+ item.d = d[i]
195
+
196
+ items.append(item)
197
+
198
+
199
+ item_pool = ItemPool(items, simulated_responses)
200
+
201
+ return item_pool
202
+
203
+ @staticmethod
204
+ def load_from_dataframe(source: DataFrame) -> "ItemPool":
205
+ """Creates item pool from a pandas DataFrame.
206
+ Required columns are: `a`, `b`, `c`, `d`.
207
+ Each column has to contain float values.
208
+ A `simulated_responses` (int values) column can be added to
209
+ the DataFrame to provide simulated responses.
210
+
211
+
212
+ Args:
213
+ source (DataFrame): _description_
214
+
215
+ Returns:
216
+ ItemPool: _description_
217
+ """
218
+
219
+ # check if columns are present
220
+ if "a" not in source.columns:
221
+ raise ValueError("Column 'a' not found.")
222
+
223
+ if "b" not in source.columns:
224
+ raise ValueError("Column 'b' not found.")
225
+
226
+ if "c" not in source.columns:
227
+ raise ValueError("Column 'c' not found.")
228
+
229
+ if "d" not in source.columns:
230
+ raise ValueError("Column 'd' not found.")
231
+
232
+ # get values
233
+ a: List[float] = source["a"].values.tolist() # type: ignore
234
+ b: List[float] = source["b"].values.tolist() # type: ignore
235
+ c: List[float] = source["c"].values.tolist() # type: ignore
236
+ d: List[float] = source["d"].values.tolist() # type: ignore
237
+
238
+ # create item pool
239
+ item_pool = ItemPool.load_from_list(a=a, b=b, c=c, d=d)
240
+
241
+ # check if simulated responses are present
242
+ if "simulated_responses" in source.columns:
243
+ simulated_responses: List[int] = source["simulated_responses"].values.tolist() # type: ignore
244
+ item_pool.simulated_responses = simulated_responses
245
+
246
+ return item_pool
@@ -0,0 +1,3 @@
1
+ class ItemSelectionException(Exception):
2
+ def __init__(self, *args):
3
+ super().__init__(*args)
@@ -0,0 +1,29 @@
1
+ from typing import Any, Dict, List
2
+ from dataclasses import dataclass
3
+
4
+
5
+ class TestItem:
6
+ def __init__(self):
7
+ """Representation of a test item in the item pool.
8
+ The format is equal to the implementation in catR.
9
+
10
+ Properties:
11
+ - a (float):
12
+ - b (float): difficulty
13
+ - c (float):
14
+ - d (float):
15
+
16
+ """
17
+ self.id: int | None = None
18
+ self.a: float = 1
19
+ self.b: float = float("nan")
20
+ self.c: float = 0
21
+ self.d: float = 1
22
+
23
+ def as_dict(self) -> dict[str, float]:
24
+ return {
25
+ "a": self.a,
26
+ "b": self.b,
27
+ "c": self.c,
28
+ "d": self.d
29
+ }
@@ -0,0 +1,23 @@
1
+ from dataclasses import dataclass
2
+ from typing import Dict
3
+
4
+
5
+ @dataclass
6
+ class TestResult:
7
+ """Representation of simulation test results"""
8
+ test_id: str
9
+ ability_estimation: float
10
+ standard_error: float
11
+ showed_item: float
12
+ response: int
13
+ true_ability_level: float
14
+
15
+ @staticmethod
16
+ def from_dict(dictionary: Dict) -> 'TestResult':
17
+ """Create a TestResult from a dictionary
18
+
19
+ Args:
20
+ dictionary: with the fields `test_id`, `ability_estimation`, `standard_error`, `showed_item`, `response`,
21
+ `true_ability_level`
22
+ """
23
+ return TestResult(**dictionary)
@@ -0,0 +1,40 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import List, Tuple
3
+ import jax.numpy as np
4
+ from ..models.__test_item import TestItem
5
+
6
+ class IEstimator(ABC):
7
+ def __init__(self,
8
+ response_pattern: List[int] | np.ndarray,
9
+ items: List[TestItem],
10
+ optimization_interval: Tuple[float, float] = (-10, 10)):
11
+ """This is the interface required for every possible
12
+ estimator.
13
+ Any estimator inherits from this class and implements
14
+ the `get_estimation` method.
15
+
16
+ Args:
17
+ response_pattern (List[int]): list of responses (0: wrong, 1:right)
18
+
19
+ items (List[TestItem]): list of answered items
20
+ """
21
+ if type(response_pattern) is not np.ndarray:
22
+ self.response_pattern = np.array(response_pattern)
23
+ else:
24
+ self.response_pattern = response_pattern
25
+ self.optimization_interval = optimization_interval
26
+
27
+ # convert items to parameter arrays
28
+ self.a = np.array([i.a for i in items])
29
+ self.b = np.array([i.b for i in items])
30
+ self.c = np.array([i.c for i in items])
31
+ self.d = np.array([i.d for i in items])
32
+
33
+ @abstractmethod
34
+ def get_estimation(self) -> float:
35
+ """Get the currently estimated ability.
36
+
37
+ Returns:
38
+ float: ability
39
+ """
40
+ pass
@@ -0,0 +1,2 @@
1
+ from .__test_results_interface import ITestResults
2
+ from .__estimator_interface import IEstimator
@@ -0,0 +1,27 @@
1
+ from abc import ABC, abstractmethod
2
+ from ..models.__test_result import TestResult
3
+ from typing import List
4
+
5
+
6
+ class ITestResults(ABC):
7
+
8
+ def __init__(self, simulation_id: str, participant_id: int):
9
+ """Interface for saving and reading test results.
10
+ This interface may mainly be used for saving simulation results.
11
+
12
+ Args:
13
+ simulation_id (str): The simulation ID. Name of the results file.
14
+
15
+ participant_id (int): The participant ID.
16
+ """
17
+ self.simulation_id = simulation_id
18
+ self.filename = f"data/{self.simulation_id}.db"
19
+ self.participant_id: int = participant_id
20
+
21
+ @abstractmethod
22
+ def save(self, test_results: List[TestResult]) -> None:
23
+ pass
24
+
25
+ @abstractmethod
26
+ def load(self) -> List[TestResult]:
27
+ pass
@@ -0,0 +1 @@
1
+ from .__simulation import *
@@ -0,0 +1,75 @@
1
+ from ..models.__adaptive_test import AdaptiveTest
2
+ from ..data.__sqlite_context import SQLiteContext
3
+ from ..data.__pickle_context import PickleContext
4
+ from ..services.__test_results_interface import ITestResults
5
+ from enum import Enum
6
+
7
+
8
+ class ResultOutputFormat(Enum):
9
+ """
10
+ Enum for selecting the output format for
11
+ the test results
12
+ """
13
+ SQLITE = 1
14
+ PICKLE = 2
15
+
16
+
17
+ class StoppingCriterion(Enum):
18
+ """Enum for selecting the stopping criterion
19
+ for the adaptive test"""
20
+ SE = 1
21
+ LENGTH = 2
22
+
23
+
24
+ class Simulation:
25
+ def __init__(self,
26
+ test: AdaptiveTest,
27
+ test_result_output: ResultOutputFormat):
28
+ """
29
+ This class can be used for simulating CAT.
30
+
31
+ Args:
32
+ test (AdaptiveTest): instance of an adaptive test implementation (see implementations module)
33
+
34
+ test_result_output (ResultOutputFormat): test results output format
35
+ """
36
+ self.test = test
37
+ self.test_result_output = test_result_output
38
+
39
+ def simulate(self,
40
+ criterion: StoppingCriterion = StoppingCriterion.SE,
41
+ value: float = 0.4):
42
+ """Runs test until the stopping criterion is met.
43
+
44
+ Args:
45
+ criterion (StoppingCriterion): selected stopping criterion
46
+
47
+ value (float): either standard error value or test length value that has to be met by the test
48
+
49
+ """
50
+ stop_test = False
51
+ while stop_test is False:
52
+ # run test
53
+ self.test.run_test_once()
54
+ # check available items
55
+ if len(self.test.item_pool.test_items) == 0:
56
+ stop_test = True
57
+ else:
58
+ if criterion == StoppingCriterion.SE:
59
+ stop_test = self.test.check_se_criterion(value)
60
+ elif criterion == StoppingCriterion.LENGTH:
61
+ stop_test = self.test.check_length_criterion(value)
62
+
63
+ def save_test_results(self):
64
+ """Saves the test results to the specified output format."""
65
+ data_context: ITestResults
66
+ if self.test_result_output == ResultOutputFormat.PICKLE:
67
+ data_context = PickleContext(simulation_id=self.test.simulation_id,
68
+ participant_id=self.test.participant_id)
69
+ else:
70
+ data_context = SQLiteContext(
71
+ simulation_id=self.test.simulation_id,
72
+ participant_id=self.test.participant_id
73
+ )
74
+ # save results
75
+ data_context.save(self.test.test_results)
File without changes