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,7 @@
1
+ from . import data
2
+ from . import implementations
3
+ from . import math
4
+ from . import models
5
+ from . import services
6
+ from . import simulation
7
+ from . import tests
@@ -0,0 +1,2 @@
1
+ from .__pickle_context import PickleContext
2
+ from .__sqlite_context import SQLiteContext
@@ -0,0 +1,51 @@
1
+ from typing import List
2
+ import pickle
3
+ import pathlib
4
+ from ..models.__test_result import TestResult
5
+ from ..services.__test_results_interface import ITestResults
6
+
7
+
8
+ class PickleContext(ITestResults):
9
+ def __init__(self,
10
+ simulation_id: str,
11
+ participant_id: int):
12
+ """Implementation of the ITestResults interface for
13
+ saving test results to the pickle format.
14
+ The resulting pickle file <simulation_id>.pickle
15
+ will be of the standard pickle format which depends
16
+ on the used python version.
17
+
18
+ Args:
19
+ simulation_id (str): Not used but required by the interface
20
+
21
+ participant_id (int): participant id and table name
22
+ """
23
+ super().__init__(simulation_id, participant_id)
24
+
25
+ def save(self, test_results: List[TestResult]) -> None:
26
+ """Saves a list of test results to a pickle binary file
27
+ (<participant_id>.pickle).
28
+
29
+ Args:
30
+ test_results (List[TestResult]): list of test results
31
+ """
32
+ dir_name = self.simulation_id
33
+
34
+ # create directory if it does not already exist
35
+ path = pathlib.Path(f"data/{dir_name}")
36
+ path.mkdir(parents=True, exist_ok=True)
37
+ # write results in file
38
+ with open(f"data/{dir_name}/{str(self.participant_id)}.pickle", "wb") as file:
39
+ pickle.dump(test_results, file)
40
+ file.close()
41
+
42
+ def load(self) -> List[TestResult]:
43
+ """Loads results from the database.
44
+ The implementation of this method is required
45
+ by the interface. However, is does not have
46
+ any implemented functionality and will throw an error
47
+ if used.
48
+
49
+ Returns: List[TestResult]
50
+ """
51
+ raise NotImplementedError("This function is not implemented.")
@@ -0,0 +1,76 @@
1
+ from typing import List
2
+ from ..models.__adaptive_test import AdaptiveTest
3
+ from ..models.__test_result import TestResult
4
+ from ..services.__test_results_interface import ITestResults
5
+ import sqlite3
6
+
7
+
8
+ class SQLiteContext(ITestResults):
9
+ def __init__(self, simulation_id: str, participant_id: int):
10
+ """Implementation of the ITestResults interface for
11
+ saving test results to a SQLITE database.
12
+ The resulting sqlite file <simulation_id>.db
13
+ will be of the SQLITE3 format.
14
+
15
+ Args:
16
+ simulation_id (str): db filename
17
+
18
+ participant_id (int): participant id and table name
19
+ """
20
+ super().__init__(simulation_id, participant_id)
21
+
22
+ def save(self, test_results: List[TestResult]) -> None:
23
+ """Saves a list of test results to the database
24
+ in the table <participant_id>.
25
+
26
+ Args:
27
+ test_results (List[TestResult]): list of test results
28
+ """
29
+
30
+
31
+ try:
32
+ con = sqlite3.connect(self.filename)
33
+ except sqlite3.OperationalError:
34
+ con = sqlite3.connect(f"../{self.filename}")
35
+
36
+ cur: sqlite3.Cursor = con.cursor()
37
+ # create table
38
+ self._create_table(cur)
39
+ # insert test results into table
40
+ for result in test_results:
41
+ sql_query = f"""
42
+ INSERT INTO p_{self.participant_id}
43
+ VALUES ("{result.test_id}", {result.ability_estimation}, {result.standard_error},
44
+ {result.showed_item}, {result.response}, {result.true_ability_level})"""
45
+ cur.execute(sql_query)
46
+ # commit changes
47
+ con.commit()
48
+ # close connection
49
+ con.close()
50
+
51
+ def load(self) -> List[TestResult]:
52
+ """Loads results from the database.
53
+ The implementation of this method is required
54
+ by the interface. However, is does not have
55
+ any implemented functionality and will throw an error.
56
+
57
+ Returns: List[TestResult]
58
+ """
59
+ raise NotImplementedError("This function is not implemented.")
60
+
61
+ def _create_table(self, cur: sqlite3.Cursor) -> None:
62
+ """Creates a table in the database with the
63
+ name <participant_id> if it does not exist.
64
+
65
+ Args:
66
+ cur (sqlite3.Cursor): database cursor
67
+ """
68
+ sql_query = f"""CREATE TABLE IF NOT EXISTS p_{self.participant_id} (
69
+ test_id,
70
+ ability_estimation,
71
+ standard_error,
72
+ showed_item,
73
+ response,
74
+ true_ability_level
75
+ )"""
76
+ cur.execute(sql_query)
@@ -0,0 +1,72 @@
1
+ from typing import List
2
+ from ..models.__adaptive_test import AdaptiveTest
3
+ from ..models.__item_pool import ItemPool
4
+ from ..models.__algorithm_exception import AlgorithmException
5
+ from ..math.estimators.__ml_estimation import MLEstimator
6
+
7
+
8
+ class DefaultImplementation(AdaptiveTest):
9
+ """This class represents the Default implementation using
10
+ Maximum Likelihood Estimation and Urry's rule during the test."""
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=True,
17
+ debug=False):
18
+ """
19
+ Args:
20
+ item_pool (ItemPool): item pool used for the test
21
+
22
+ simulation_id (str): simulation id
23
+
24
+ participant_id (int): participant id
25
+
26
+ true_ability_level (float): true ability level (must always be set)
27
+
28
+ initial_ability_level (float): initially assumed ability level
29
+
30
+ simulation (bool): will the test be simulated
31
+
32
+ debug (bool): enables debug mode
33
+
34
+ """
35
+ super().__init__(item_pool,
36
+ simulation_id,
37
+ participant_id,
38
+ true_ability_level,
39
+ initial_ability_level,
40
+ simulation,
41
+ debug)
42
+
43
+ def estimate_ability_level(self) -> float:
44
+ """
45
+ Estimates latent ability level using ML.
46
+ If responses are only 1 or 0,
47
+ the ability will be set to one
48
+ of the boundaries of the estimation interval (`[-10,10]`).
49
+
50
+ Returns:
51
+ float: ability estimation
52
+ """
53
+ estimator = MLEstimator(
54
+ self.response_pattern,
55
+ self.get_answered_items()
56
+ )
57
+ estimation: float = float("NaN")
58
+ try:
59
+ estimation = estimator.get_estimation()
60
+ except AlgorithmException as exception:
61
+ # check if all responses are the same
62
+ if len(set(self.response_pattern)) == 1:
63
+ if self.response_pattern[0] == 0:
64
+ estimation = -10
65
+ elif self.response_pattern[0] == 1:
66
+ estimation = 10
67
+
68
+ else:
69
+ raise AlgorithmException("""Something else
70
+ when wrong when running MLE""") from exception
71
+
72
+ return estimation
@@ -0,0 +1,3 @@
1
+ from .__default_implementation import DefaultImplementation
2
+ from .__pre_test import PreTest
3
+ from .__semi_implementation import SemiAdaptiveImplementation
@@ -0,0 +1,83 @@
1
+ from typing import List
2
+ import jax.numpy as np
3
+ from ..models.__test_item import TestItem
4
+ import random
5
+
6
+
7
+ class PreTest:
8
+ def __init__(self, items: List[TestItem], seed: int | None = None):
9
+ """
10
+ The pretest class can be used to draw items randomly from
11
+ difficulty quantiles
12
+ of the item pool.
13
+
14
+ Args:
15
+ items: Item pool
16
+
17
+ seed (int): A seed for the item selection can be provided.
18
+ If not, the item selection will be drawn randomly, and you will not be able
19
+ to reproduce the results.
20
+
21
+ """
22
+ self.items = items
23
+ self.seed = seed
24
+
25
+ def calculate_quantiles(self) -> np.ndarray:
26
+ """Calculates quantiles 0.25, 0.5, 0.75
27
+ """
28
+ # get difficulties
29
+ difficulties: List[float] = [item.b for item in self.items]
30
+
31
+ quantiles = np.array([])
32
+ # calculate quantiles
33
+ for q in [0.25, 0.5, 0.75]:
34
+ quantile: np.ndarray = np.quantile(np.array(difficulties), q)
35
+ quantiles = np.append(quantiles, quantile)
36
+
37
+ return quantiles
38
+
39
+ def select_item_in_interval(self, lower: float, upper: float) -> TestItem:
40
+ """Draws item from a pool in the specified interval.
41
+ The item difficulty is > than the lower limit and <= the higher limit.
42
+
43
+ Args:
44
+ lower (float): Lower bound of the item difficulty interval.
45
+
46
+ upper (float): Upper bound of the item difficulty interval.
47
+
48
+ Returns:
49
+ TestItem: Selected item.
50
+ """
51
+ # select only items with difficulty in interval
52
+ items_in_interval: List[TestItem] = [item for item in list(self.items) if lower < item.b <= upper]
53
+ # draw one item randomly
54
+ if self.seed is not None:
55
+ random.seed(self.seed)
56
+ item = random.sample(items_in_interval, 1)[0]
57
+ return item
58
+
59
+ def select_random_item_quantile(self) -> List[TestItem]:
60
+ """Selects a random item from the 0.25, 0.5 and 0.75 quantiles.
61
+
62
+ Returns:
63
+ List[TestItem]: Selected item.
64
+ """
65
+ selected_items: List[TestItem] = []
66
+ # create difficulties array
67
+ difficulties = np.array([item.b for item in self.items])
68
+ # add minimum and maximum difficulty values to
69
+ # the quantiles list for correct item selection
70
+ quantiles = [difficulties.min()]
71
+ quantiles.extend(list(self.calculate_quantiles()))
72
+ quantiles.extend([difficulties.max()])
73
+
74
+ for i in range(len(quantiles)-1):
75
+ selected_item = self.select_item_in_interval(
76
+ lower=float(quantiles[i]),
77
+ upper=float(quantiles[i+1])
78
+ )
79
+
80
+ selected_items.append(selected_item)
81
+
82
+ # return
83
+ return selected_items
@@ -0,0 +1,134 @@
1
+ from typing import List
2
+ from ..models.__adaptive_test import AdaptiveTest
3
+ from ..models.__item_pool import ItemPool
4
+ from ..models.__algorithm_exception import AlgorithmException
5
+ from ..models.__test_result import TestResult
6
+ from ..math.estimators.__ml_estimation import MLEstimator
7
+ from .__pre_test import PreTest
8
+
9
+
10
+ class SemiAdaptiveImplementation(AdaptiveTest):
11
+
12
+ def __init__(self,
13
+ item_pool: ItemPool,
14
+ simulation_id: str,
15
+ participant_id: int,
16
+ true_ability_level: float,
17
+ initial_ability_level: float = 0,
18
+ simulation=True,
19
+ debug=False,
20
+ pretest_seed=12345):
21
+ """
22
+ This class represents the Semi-Adaptive implementation using
23
+ Maximum Likelihood Estimation and Urry's rule during the test.
24
+ The pretest is 4 items long.
25
+
26
+ Args:
27
+ item_pool (ItemPool): item pool used for the test
28
+
29
+ simulation_id (str): simulation id
30
+
31
+ participant_id (int): participant id
32
+
33
+ true_ability_level (float): true ability level (must always be set)
34
+
35
+ initial_ability_level (float): initially assumed ability level
36
+
37
+ simulation (bool): will the test be simulated
38
+
39
+ debug (bool): enables debug mode
40
+
41
+ pretest_seed (int): seed used for the random number generator to draw pretest items.
42
+
43
+ """
44
+
45
+ super().__init__(item_pool,
46
+ simulation_id,
47
+ participant_id,
48
+ true_ability_level,
49
+ initial_ability_level,
50
+ simulation,
51
+ debug)
52
+
53
+ self.pretest_seed = pretest_seed
54
+
55
+ def estimate_ability_level(self) -> float:
56
+ """
57
+ Estimates latent ability level using ML.
58
+ If responses are only 1 or 0,
59
+ the ability will be set to one
60
+ of the boundaries of the estimation interval (`[-10,10]`).
61
+
62
+ Returns:
63
+ float: ability estimation
64
+ """
65
+ estimator = MLEstimator(
66
+ self.response_pattern,
67
+ self.get_answered_items()
68
+ )
69
+ estimation: float = float("NaN")
70
+ try:
71
+ estimation = estimator.get_estimation()
72
+ except AlgorithmException as exception:
73
+ # check if all responses are the same
74
+ if len(set(self.response_pattern)) == 1:
75
+ if self.response_pattern[0] == 0:
76
+ estimation = -10
77
+ elif self.response_pattern[0] == 1:
78
+ estimation = 10
79
+
80
+ else:
81
+ raise AlgorithmException("""Something else
82
+ when wrong when running MLE""") from exception
83
+
84
+ return estimation
85
+
86
+ def pre_test(self):
87
+ """Runs pretest"""
88
+ # create test instance
89
+ pretest = PreTest(
90
+ items=self.item_pool.test_items,
91
+ seed=self.pretest_seed
92
+ )
93
+ # get selected items
94
+ random_items = pretest.select_random_item_quantile()
95
+
96
+ # get responses
97
+ for item in random_items:
98
+ response = self.item_pool.get_item_response(item)
99
+ # add response to response pattern
100
+ self.response_pattern.append(response)
101
+ # add item to answered items list
102
+ self.answered_items.append(item)
103
+
104
+ # remove random items from item pool
105
+ for item in random_items:
106
+ self.item_pool.delete_item(item)
107
+
108
+ # estimate ability level
109
+ estimation = self.estimate_ability_level()
110
+ self.ability_level = estimation
111
+
112
+ # create test results for all n-1 random items
113
+ for i in range(0, len(random_items) - 1):
114
+ result = TestResult(
115
+ ability_estimation=float("nan"),
116
+ standard_error=float("nan"),
117
+ showed_item=random_items[i].b,
118
+ response=self.response_pattern[i],
119
+ test_id=self.simulation_id,
120
+ true_ability_level=self.true_ability_level,
121
+ )
122
+ # append to memory
123
+ self.test_results.append(result)
124
+
125
+ # create test result for first ability estimation
126
+ intermediate_result = TestResult(
127
+ ability_estimation=self.ability_level,
128
+ standard_error=self.get_ability_se(),
129
+ showed_item=random_items[-1].b,
130
+ response=self.response_pattern[-1],
131
+ test_id=self.simulation_id,
132
+ true_ability_level=self.true_ability_level,
133
+ )
134
+ self.test_results.append(intermediate_result)
@@ -0,0 +1,2 @@
1
+ from .__standard_error import standard_error
2
+ from .__test_information import test_information_function
@@ -0,0 +1,59 @@
1
+ import math
2
+ from typing import List, Literal
3
+ import jax.numpy as np
4
+ from .__test_information import test_information_function
5
+ from ..models.__test_item import TestItem
6
+
7
+
8
+ def standard_error(answered_items: List[TestItem],
9
+ estimated_ability_level: float,
10
+ estimator: Literal["ML", "BM"] = "ML",
11
+ sd: float | None = None) -> float:
12
+ """Calculates the standard error using the test information function.
13
+ If Bayes Modal is used for the ability estimation, a standard deviation value
14
+ of the prior distribution has to be provided.
15
+
16
+ Args:
17
+ answered_items (List[float]): List of answered items
18
+
19
+ estimated_ability_level (float): Currently estimated ability level
20
+
21
+ estimator (Literal["ML", "BM"]): Ability estimator (Default: ML)
22
+
23
+ sd (float | None): Standard deviation of the prior distribution. Only required for BM.
24
+
25
+ Raises:
26
+ ValueError
27
+
28
+ Returns:
29
+ float: Standard error
30
+ """
31
+ a=np.array([item.a for item in answered_items])
32
+ b=np.array([item.b for item in answered_items])
33
+ c=np.array([item.c for item in answered_items])
34
+ d=np.array([item.d for item in answered_items])
35
+
36
+ if estimator == "ML":
37
+ error = 1 / math.sqrt(test_information_function(mu=np.array(estimated_ability_level),
38
+ a=a,
39
+ b=b,
40
+ c=c,
41
+ d=d
42
+ ))
43
+
44
+ return error
45
+
46
+ if estimator == "BM":
47
+ information = test_information_function(mu=np.array(estimated_ability_level),
48
+ a=a,
49
+ b=b,
50
+ c=c,
51
+ d=d)
52
+ if sd is None:
53
+ raise ValueError("sd cannot be None if BM is used as estimator.")
54
+
55
+ error = 1 / math.sqrt(
56
+ (1 / math.pow(sd, 2)) + information
57
+ )
58
+
59
+ return error
@@ -0,0 +1,43 @@
1
+ import jax.numpy as np
2
+ from .estimators.__functions.__estimators import probability_y1, probability_y0
3
+ from jax import jit, grad
4
+ from scipy.differentiate import derivative
5
+
6
+
7
+
8
+ def test_information_function(
9
+ mu: np.ndarray,
10
+ a: np.ndarray,
11
+ b: np.ndarray,
12
+ c: np.ndarray,
13
+ d: np.ndarray,
14
+ ) -> float:
15
+ """
16
+ Calculates test information.
17
+
18
+ Args:
19
+ mu (np.ndarray): ability level
20
+ a (np.ndarray): discrimination parameter
21
+ b (np.ndarray): difficulty parameter
22
+ c (np.ndarray): guessing parameter
23
+ d (np.ndarray): slipping parameter
24
+
25
+ Returns:
26
+ float: test information
27
+
28
+ """
29
+ p_y1 = probability_y1(mu, a, b, c, d)
30
+
31
+ p_y1_grad = derivative(probability_y1,
32
+ mu,
33
+ args=(a, b, c, d))
34
+
35
+
36
+
37
+ product = (p_y1_grad.df ** 2) / (p_y1 * (1 - p_y1))
38
+ information_tensor = np.cumsum(product)
39
+ # print(f"Information tensor: {information_tensor}")
40
+ information = information_tensor[len(information_tensor) - 1]
41
+ # convert information to float and return
42
+ return float(information)
43
+