edsl 0.1.39__py3-none-any.whl → 0.1.39.dev1__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.
- edsl/Base.py +116 -197
- edsl/__init__.py +7 -15
- edsl/__version__.py +1 -1
- edsl/agents/Agent.py +147 -351
- edsl/agents/AgentList.py +73 -211
- edsl/agents/Invigilator.py +50 -101
- edsl/agents/InvigilatorBase.py +70 -62
- edsl/agents/PromptConstructor.py +225 -143
- edsl/agents/__init__.py +1 -0
- edsl/agents/prompt_helpers.py +3 -3
- edsl/auto/AutoStudy.py +5 -18
- edsl/auto/StageBase.py +40 -53
- edsl/auto/StageQuestions.py +1 -2
- edsl/auto/utilities.py +6 -0
- edsl/config.py +2 -22
- edsl/conversation/car_buying.py +1 -2
- edsl/coop/PriceFetcher.py +1 -1
- edsl/coop/coop.py +47 -125
- edsl/coop/utils.py +14 -14
- edsl/data/Cache.py +27 -45
- edsl/data/CacheEntry.py +15 -12
- edsl/data/CacheHandler.py +12 -31
- edsl/data/RemoteCacheSync.py +46 -154
- edsl/data/__init__.py +3 -4
- edsl/data_transfer_models.py +1 -2
- edsl/enums.py +0 -27
- edsl/exceptions/__init__.py +50 -50
- edsl/exceptions/agents.py +0 -12
- edsl/exceptions/questions.py +6 -24
- edsl/exceptions/scenarios.py +0 -7
- edsl/inference_services/AnthropicService.py +19 -38
- edsl/inference_services/AwsBedrock.py +2 -0
- edsl/inference_services/AzureAI.py +2 -0
- edsl/inference_services/GoogleService.py +12 -7
- edsl/inference_services/InferenceServiceABC.py +85 -18
- edsl/inference_services/InferenceServicesCollection.py +79 -120
- edsl/inference_services/MistralAIService.py +3 -0
- edsl/inference_services/OpenAIService.py +35 -47
- edsl/inference_services/PerplexityService.py +3 -0
- edsl/inference_services/TestService.py +10 -11
- edsl/inference_services/TogetherAIService.py +3 -5
- edsl/jobs/Answers.py +14 -1
- edsl/jobs/Jobs.py +431 -356
- edsl/jobs/JobsChecks.py +10 -35
- edsl/jobs/JobsPrompts.py +4 -6
- edsl/jobs/JobsRemoteInferenceHandler.py +133 -205
- edsl/jobs/buckets/BucketCollection.py +3 -44
- edsl/jobs/buckets/TokenBucket.py +21 -53
- edsl/jobs/interviews/Interview.py +408 -143
- edsl/jobs/runners/JobsRunnerAsyncio.py +403 -88
- edsl/jobs/runners/JobsRunnerStatus.py +165 -133
- edsl/jobs/tasks/QuestionTaskCreator.py +19 -21
- edsl/jobs/tasks/TaskHistory.py +18 -38
- edsl/jobs/tasks/task_status_enum.py +2 -0
- edsl/language_models/KeyLookup.py +30 -0
- edsl/language_models/LanguageModel.py +236 -194
- edsl/language_models/ModelList.py +19 -28
- edsl/language_models/__init__.py +2 -1
- edsl/language_models/registry.py +190 -0
- edsl/language_models/repair.py +2 -2
- edsl/language_models/unused/ReplicateBase.py +83 -0
- edsl/language_models/utilities.py +4 -5
- edsl/notebooks/Notebook.py +14 -19
- edsl/prompts/Prompt.py +39 -29
- edsl/questions/{answer_validator_mixin.py → AnswerValidatorMixin.py} +2 -47
- edsl/questions/QuestionBase.py +214 -68
- edsl/questions/{question_base_gen_mixin.py → QuestionBaseGenMixin.py} +50 -57
- edsl/questions/QuestionBasePromptsMixin.py +3 -7
- edsl/questions/QuestionBudget.py +1 -1
- edsl/questions/QuestionCheckBox.py +3 -3
- edsl/questions/QuestionExtract.py +7 -5
- edsl/questions/QuestionFreeText.py +3 -2
- edsl/questions/QuestionList.py +18 -10
- edsl/questions/QuestionMultipleChoice.py +23 -67
- edsl/questions/QuestionNumerical.py +4 -2
- edsl/questions/QuestionRank.py +17 -7
- edsl/questions/{response_validator_abc.py → ResponseValidatorABC.py} +26 -40
- edsl/questions/SimpleAskMixin.py +3 -4
- edsl/questions/__init__.py +1 -2
- edsl/questions/derived/QuestionLinearScale.py +3 -6
- edsl/questions/derived/QuestionTopK.py +1 -1
- edsl/questions/descriptors.py +3 -17
- edsl/questions/question_registry.py +1 -1
- edsl/results/CSSParameterizer.py +1 -1
- edsl/results/Dataset.py +7 -170
- edsl/results/DatasetExportMixin.py +305 -168
- edsl/results/DatasetTree.py +8 -28
- edsl/results/Result.py +206 -298
- edsl/results/Results.py +131 -149
- edsl/results/ResultsDBMixin.py +238 -0
- edsl/results/ResultsExportMixin.py +0 -2
- edsl/results/{results_selector.py → Selector.py} +13 -23
- edsl/results/TableDisplay.py +171 -98
- edsl/results/__init__.py +1 -1
- edsl/scenarios/FileStore.py +239 -150
- edsl/scenarios/Scenario.py +193 -90
- edsl/scenarios/ScenarioHtmlMixin.py +3 -4
- edsl/scenarios/{scenario_join.py → ScenarioJoin.py} +6 -10
- edsl/scenarios/ScenarioList.py +244 -415
- edsl/scenarios/ScenarioListExportMixin.py +7 -0
- edsl/scenarios/ScenarioListPdfMixin.py +37 -15
- edsl/scenarios/__init__.py +2 -1
- edsl/study/ObjectEntry.py +1 -1
- edsl/study/SnapShot.py +1 -1
- edsl/study/Study.py +12 -5
- edsl/surveys/Rule.py +4 -5
- edsl/surveys/RuleCollection.py +27 -25
- edsl/surveys/Survey.py +791 -270
- edsl/surveys/SurveyCSS.py +8 -20
- edsl/surveys/{SurveyFlowVisualization.py → SurveyFlowVisualizationMixin.py} +9 -11
- edsl/surveys/__init__.py +2 -4
- edsl/surveys/descriptors.py +2 -6
- edsl/surveys/instructions/ChangeInstruction.py +2 -1
- edsl/surveys/instructions/Instruction.py +13 -4
- edsl/surveys/instructions/InstructionCollection.py +6 -11
- edsl/templates/error_reporting/interview_details.html +1 -1
- edsl/templates/error_reporting/report.html +1 -1
- edsl/tools/plotting.py +1 -1
- edsl/utilities/utilities.py +23 -35
- {edsl-0.1.39.dist-info → edsl-0.1.39.dev1.dist-info}/METADATA +10 -12
- edsl-0.1.39.dev1.dist-info/RECORD +277 -0
- {edsl-0.1.39.dist-info → edsl-0.1.39.dev1.dist-info}/WHEEL +1 -1
- edsl/agents/QuestionInstructionPromptBuilder.py +0 -128
- edsl/agents/QuestionTemplateReplacementsBuilder.py +0 -137
- edsl/agents/question_option_processor.py +0 -172
- edsl/coop/CoopFunctionsMixin.py +0 -15
- edsl/coop/ExpectedParrotKeyHandler.py +0 -125
- edsl/exceptions/inference_services.py +0 -5
- edsl/inference_services/AvailableModelCacheHandler.py +0 -184
- edsl/inference_services/AvailableModelFetcher.py +0 -215
- edsl/inference_services/ServiceAvailability.py +0 -135
- edsl/inference_services/data_structures.py +0 -134
- edsl/jobs/AnswerQuestionFunctionConstructor.py +0 -223
- edsl/jobs/FetchInvigilator.py +0 -47
- edsl/jobs/InterviewTaskManager.py +0 -98
- edsl/jobs/InterviewsConstructor.py +0 -50
- edsl/jobs/JobsComponentConstructor.py +0 -189
- edsl/jobs/JobsRemoteInferenceLogger.py +0 -239
- edsl/jobs/RequestTokenEstimator.py +0 -30
- edsl/jobs/async_interview_runner.py +0 -138
- edsl/jobs/buckets/TokenBucketAPI.py +0 -211
- edsl/jobs/buckets/TokenBucketClient.py +0 -191
- edsl/jobs/check_survey_scenario_compatibility.py +0 -85
- edsl/jobs/data_structures.py +0 -120
- edsl/jobs/decorators.py +0 -35
- edsl/jobs/jobs_status_enums.py +0 -9
- edsl/jobs/loggers/HTMLTableJobLogger.py +0 -304
- edsl/jobs/results_exceptions_handler.py +0 -98
- edsl/language_models/ComputeCost.py +0 -63
- edsl/language_models/PriceManager.py +0 -127
- edsl/language_models/RawResponseHandler.py +0 -106
- edsl/language_models/ServiceDataSources.py +0 -0
- edsl/language_models/key_management/KeyLookup.py +0 -63
- edsl/language_models/key_management/KeyLookupBuilder.py +0 -273
- edsl/language_models/key_management/KeyLookupCollection.py +0 -38
- edsl/language_models/key_management/__init__.py +0 -0
- edsl/language_models/key_management/models.py +0 -131
- edsl/language_models/model.py +0 -256
- edsl/notebooks/NotebookToLaTeX.py +0 -142
- edsl/questions/ExceptionExplainer.py +0 -77
- edsl/questions/HTMLQuestion.py +0 -103
- edsl/questions/QuestionMatrix.py +0 -265
- edsl/questions/data_structures.py +0 -20
- edsl/questions/loop_processor.py +0 -149
- edsl/questions/response_validator_factory.py +0 -34
- edsl/questions/templates/matrix/__init__.py +0 -1
- edsl/questions/templates/matrix/answering_instructions.jinja +0 -5
- edsl/questions/templates/matrix/question_presentation.jinja +0 -20
- edsl/results/MarkdownToDocx.py +0 -122
- edsl/results/MarkdownToPDF.py +0 -111
- edsl/results/TextEditor.py +0 -50
- edsl/results/file_exports.py +0 -252
- edsl/results/smart_objects.py +0 -96
- edsl/results/table_data_class.py +0 -12
- edsl/results/table_renderers.py +0 -118
- edsl/scenarios/ConstructDownloadLink.py +0 -109
- edsl/scenarios/DocumentChunker.py +0 -102
- edsl/scenarios/DocxScenario.py +0 -16
- edsl/scenarios/PdfExtractor.py +0 -40
- edsl/scenarios/directory_scanner.py +0 -96
- edsl/scenarios/file_methods.py +0 -85
- edsl/scenarios/handlers/__init__.py +0 -13
- edsl/scenarios/handlers/csv.py +0 -49
- edsl/scenarios/handlers/docx.py +0 -76
- edsl/scenarios/handlers/html.py +0 -37
- edsl/scenarios/handlers/json.py +0 -111
- edsl/scenarios/handlers/latex.py +0 -5
- edsl/scenarios/handlers/md.py +0 -51
- edsl/scenarios/handlers/pdf.py +0 -68
- edsl/scenarios/handlers/png.py +0 -39
- edsl/scenarios/handlers/pptx.py +0 -105
- edsl/scenarios/handlers/py.py +0 -294
- edsl/scenarios/handlers/sql.py +0 -313
- edsl/scenarios/handlers/sqlite.py +0 -149
- edsl/scenarios/handlers/txt.py +0 -33
- edsl/scenarios/scenario_selector.py +0 -156
- edsl/surveys/ConstructDAG.py +0 -92
- edsl/surveys/EditSurvey.py +0 -221
- edsl/surveys/InstructionHandler.py +0 -100
- edsl/surveys/MemoryManagement.py +0 -72
- edsl/surveys/RuleManager.py +0 -172
- edsl/surveys/Simulator.py +0 -75
- edsl/surveys/SurveyToApp.py +0 -141
- edsl/utilities/PrettyList.py +0 -56
- edsl/utilities/is_notebook.py +0 -18
- edsl/utilities/is_valid_variable_name.py +0 -11
- edsl/utilities/remove_edsl_version.py +0 -24
- edsl-0.1.39.dist-info/RECORD +0 -358
- /edsl/questions/{register_questions_meta.py → RegisterQuestionsMeta.py} +0 -0
- /edsl/results/{results_fetch_mixin.py → ResultsFetchMixin.py} +0 -0
- /edsl/results/{results_tools_mixin.py → ResultsToolsMixin.py} +0 -0
- {edsl-0.1.39.dist-info → edsl-0.1.39.dev1.dist-info}/LICENSE +0 -0
edsl/surveys/Survey.py
CHANGED
@@ -2,93 +2,43 @@
|
|
2
2
|
|
3
3
|
from __future__ import annotations
|
4
4
|
import re
|
5
|
-
import
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
Generator,
|
10
|
-
Optional,
|
11
|
-
Union,
|
12
|
-
List,
|
13
|
-
Literal,
|
14
|
-
Callable,
|
15
|
-
TYPE_CHECKING,
|
16
|
-
)
|
5
|
+
import tempfile
|
6
|
+
import requests
|
7
|
+
|
8
|
+
from typing import Any, Generator, Optional, Union, List, Literal, Callable
|
17
9
|
from uuid import uuid4
|
18
10
|
from edsl.Base import Base
|
19
|
-
from edsl.exceptions
|
11
|
+
from edsl.exceptions import SurveyCreationError, SurveyHasNoRulesError
|
20
12
|
from edsl.exceptions.surveys import SurveyError
|
21
|
-
from collections import UserDict
|
22
|
-
|
23
|
-
|
24
|
-
class PseudoIndices(UserDict):
|
25
|
-
@property
|
26
|
-
def max_pseudo_index(self) -> float:
|
27
|
-
"""Return the maximum pseudo index in the survey.
|
28
|
-
>>> Survey.example()._pseudo_indices.max_pseudo_index
|
29
|
-
2
|
30
|
-
"""
|
31
|
-
if len(self) == 0:
|
32
|
-
return -1
|
33
|
-
return max(self.values())
|
34
|
-
|
35
|
-
@property
|
36
|
-
def last_item_was_instruction(self) -> bool:
|
37
|
-
"""Return whether the last item added to the survey was an instruction.
|
38
|
-
|
39
|
-
This is used to determine the pseudo-index of the next item added to the survey.
|
40
|
-
|
41
|
-
Example:
|
42
|
-
|
43
|
-
>>> s = Survey.example()
|
44
|
-
>>> s._pseudo_indices.last_item_was_instruction
|
45
|
-
False
|
46
|
-
>>> from edsl.surveys.instructions.Instruction import Instruction
|
47
|
-
>>> s = s.add_instruction(Instruction(text="Pay attention to the following questions.", name="intro"))
|
48
|
-
>>> s._pseudo_indices.last_item_was_instruction
|
49
|
-
True
|
50
|
-
"""
|
51
|
-
return isinstance(self.max_pseudo_index, float)
|
52
|
-
|
53
|
-
|
54
|
-
if TYPE_CHECKING:
|
55
|
-
from edsl.questions.QuestionBase import QuestionBase
|
56
|
-
from edsl.agents.Agent import Agent
|
57
|
-
from edsl.surveys.DAG import DAG
|
58
|
-
from edsl.language_models.LanguageModel import LanguageModel
|
59
|
-
from edsl.scenarios.Scenario import Scenario
|
60
|
-
from edsl.data.Cache import Cache
|
61
|
-
|
62
|
-
# This is a hack to get around the fact that TypeAlias is not available in typing until Python 3.10
|
63
|
-
try:
|
64
|
-
from typing import TypeAlias
|
65
|
-
except ImportError:
|
66
|
-
from typing import _GenericAlias as TypeAlias
|
67
|
-
|
68
|
-
QuestionType: TypeAlias = Union[QuestionBase, Instruction, ChangeInstruction]
|
69
|
-
QuestionGroupType: TypeAlias = dict[str, tuple[int, int]]
|
70
13
|
|
14
|
+
from edsl.questions.QuestionBase import QuestionBase
|
15
|
+
from edsl.surveys.base import RulePriority, EndOfSurvey
|
16
|
+
from edsl.surveys.DAG import DAG
|
17
|
+
from edsl.surveys.descriptors import QuestionsDescriptor
|
18
|
+
from edsl.surveys.MemoryPlan import MemoryPlan
|
19
|
+
from edsl.surveys.Rule import Rule
|
20
|
+
from edsl.surveys.RuleCollection import RuleCollection
|
21
|
+
from edsl.surveys.SurveyExportMixin import SurveyExportMixin
|
22
|
+
from edsl.surveys.SurveyFlowVisualizationMixin import SurveyFlowVisualizationMixin
|
23
|
+
from edsl.utilities.decorators import add_edsl_version, remove_edsl_version
|
71
24
|
|
72
|
-
from edsl.
|
25
|
+
from edsl.agents.Agent import Agent
|
73
26
|
|
74
27
|
from edsl.surveys.instructions.InstructionCollection import InstructionCollection
|
75
28
|
from edsl.surveys.instructions.Instruction import Instruction
|
76
29
|
from edsl.surveys.instructions.ChangeInstruction import ChangeInstruction
|
77
30
|
|
78
|
-
from edsl.surveys.base import EndOfSurvey
|
79
|
-
from edsl.surveys.descriptors import QuestionsDescriptor
|
80
|
-
from edsl.surveys.MemoryPlan import MemoryPlan
|
81
|
-
from edsl.surveys.RuleCollection import RuleCollection
|
82
|
-
from edsl.surveys.SurveyExportMixin import SurveyExportMixin
|
83
|
-
from edsl.surveys.SurveyFlowVisualization import SurveyFlowVisualization
|
84
|
-
from edsl.surveys.InstructionHandler import InstructionHandler
|
85
|
-
from edsl.surveys.EditSurvey import EditSurvey
|
86
|
-
from edsl.surveys.Simulator import Simulator
|
87
|
-
from edsl.surveys.MemoryManagement import MemoryManagement
|
88
|
-
from edsl.surveys.RuleManager import RuleManager
|
89
31
|
|
32
|
+
class ValidatedString(str):
|
33
|
+
def __new__(cls, content):
|
34
|
+
if "<>" in content:
|
35
|
+
raise SurveyCreationError(
|
36
|
+
"The expression contains '<>', which is not allowed. You probably mean '!='."
|
37
|
+
)
|
38
|
+
return super().__new__(cls, content)
|
90
39
|
|
91
|
-
|
40
|
+
|
41
|
+
class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
|
92
42
|
"""A collection of questions that supports skip logic."""
|
93
43
|
|
94
44
|
__documentation__ = """https://docs.expectedparrot.com/en/latest/surveys.html"""
|
@@ -111,12 +61,13 @@ class Survey(SurveyExportMixin, Base):
|
|
111
61
|
|
112
62
|
def __init__(
|
113
63
|
self,
|
114
|
-
questions: Optional[
|
115
|
-
|
116
|
-
|
117
|
-
|
64
|
+
questions: Optional[
|
65
|
+
list[Union[QuestionBase, Instruction, ChangeInstruction]]
|
66
|
+
] = None,
|
67
|
+
memory_plan: Optional[MemoryPlan] = None,
|
68
|
+
rule_collection: Optional[RuleCollection] = None,
|
69
|
+
question_groups: Optional[dict[str, tuple[int, int]]] = None,
|
118
70
|
name: Optional[str] = None,
|
119
|
-
questions_to_randomize: Optional[List[str]] = None,
|
120
71
|
):
|
121
72
|
"""Create a new survey.
|
122
73
|
|
@@ -138,7 +89,11 @@ class Survey(SurveyExportMixin, Base):
|
|
138
89
|
|
139
90
|
self.raw_passed_questions = questions
|
140
91
|
|
141
|
-
|
92
|
+
(
|
93
|
+
true_questions,
|
94
|
+
instruction_names_to_instructions,
|
95
|
+
self.pseudo_indices,
|
96
|
+
) = self._separate_questions_and_instructions(questions or [])
|
142
97
|
|
143
98
|
self.rule_collection = RuleCollection(
|
144
99
|
num_questions=len(true_questions) if true_questions else None
|
@@ -146,9 +101,8 @@ class Survey(SurveyExportMixin, Base):
|
|
146
101
|
# the RuleCollection needs to be present while we add the questions; we might override this later
|
147
102
|
# if a rule_collection is provided. This allows us to serialize the survey with the rule_collection.
|
148
103
|
|
149
|
-
# this is where the Questions constructor is called.
|
150
104
|
self.questions = true_questions
|
151
|
-
|
105
|
+
self.instruction_names_to_instructions = instruction_names_to_instructions
|
152
106
|
|
153
107
|
self.memory_plan = memory_plan or MemoryPlan(self)
|
154
108
|
if question_groups is not None:
|
@@ -156,7 +110,7 @@ class Survey(SurveyExportMixin, Base):
|
|
156
110
|
else:
|
157
111
|
self.question_groups = {}
|
158
112
|
|
159
|
-
# if a rule collection is provided, use it instead
|
113
|
+
# if a rule collection is provided, use it instead
|
160
114
|
if rule_collection is not None:
|
161
115
|
self.rule_collection = rule_collection
|
162
116
|
|
@@ -165,58 +119,97 @@ class Survey(SurveyExportMixin, Base):
|
|
165
119
|
|
166
120
|
warnings.warn("name parameter to a survey is deprecated.")
|
167
121
|
|
168
|
-
|
169
|
-
self.questions_to_randomize = questions_to_randomize
|
170
|
-
else:
|
171
|
-
self.questions_to_randomize = []
|
172
|
-
|
173
|
-
self._seed = None
|
174
|
-
|
175
|
-
def draw(self) -> "Survey":
|
176
|
-
"""Return a new survey with a randomly selected permutation of the options."""
|
177
|
-
if self._seed is None: # only set once
|
178
|
-
self._seed = hash(self)
|
179
|
-
random.seed(self._seed)
|
180
|
-
|
181
|
-
if len(self.questions_to_randomize) == 0:
|
182
|
-
return self
|
183
|
-
|
184
|
-
new_questions = []
|
185
|
-
for question in self.questions:
|
186
|
-
if question.question_name in self.questions_to_randomize:
|
187
|
-
new_questions.append(question.draw())
|
188
|
-
else:
|
189
|
-
new_questions.append(question.duplicate())
|
190
|
-
|
191
|
-
d = self.to_dict()
|
192
|
-
d["questions"] = [q.to_dict() for q in new_questions]
|
193
|
-
return Survey.from_dict(d)
|
194
|
-
|
195
|
-
def _process_raw_questions(self, questions: Optional[List["QuestionType"]]) -> list:
|
196
|
-
"""Process the raw questions passed to the survey."""
|
197
|
-
handler = InstructionHandler(self)
|
198
|
-
components = handler.separate_questions_and_instructions(questions or [])
|
199
|
-
self._instruction_names_to_instructions = (
|
200
|
-
components.instruction_names_to_instructions
|
201
|
-
)
|
202
|
-
self._pseudo_indices = PseudoIndices(components.pseudo_indices)
|
203
|
-
return components.true_questions
|
204
|
-
|
205
|
-
# region: Survey instruction handling
|
122
|
+
# region: Suvry instruction handling
|
206
123
|
@property
|
207
|
-
def
|
124
|
+
def relevant_instructions_dict(self) -> InstructionCollection:
|
208
125
|
"""Return a dictionary with keys as question names and values as instructions that are relevant to the question.
|
209
126
|
|
210
127
|
>>> s = Survey.example(include_instructions=True)
|
211
|
-
>>> s.
|
128
|
+
>>> s.relevant_instructions_dict
|
212
129
|
{'q0': [Instruction(name="attention", text="Please pay attention!")], 'q1': [Instruction(name="attention", text="Please pay attention!")], 'q2': [Instruction(name="attention", text="Please pay attention!")]}
|
213
130
|
|
214
131
|
"""
|
215
132
|
return InstructionCollection(
|
216
|
-
self.
|
133
|
+
self.instruction_names_to_instructions, self.questions
|
217
134
|
)
|
218
135
|
|
219
|
-
|
136
|
+
@staticmethod
|
137
|
+
def _separate_questions_and_instructions(questions_and_instructions: list) -> tuple:
|
138
|
+
"""
|
139
|
+
The 'pseudo_indices' attribute is a dictionary that maps question names to pseudo-indices
|
140
|
+
that are used to order questions and instructions in the survey.
|
141
|
+
Only questions get real indices; instructions get pseudo-indices.
|
142
|
+
However, the order of the pseudo-indices is the same as the order questions and instructions are added to the survey.
|
143
|
+
|
144
|
+
We don't have to know how many instructions there are to calculate the pseudo-indices because they are
|
145
|
+
calculated by the inverse of one minus the sum of 1/2^n for n in the number of instructions run so far.
|
146
|
+
|
147
|
+
>>> from edsl import Instruction
|
148
|
+
>>> i = Instruction(text = "Pay attention to the following questions.", name = "intro")
|
149
|
+
>>> i2 = Instruction(text = "How are you feeling today?", name = "followon_intro")
|
150
|
+
>>> from edsl import QuestionFreeText; q1 = QuestionFreeText.example()
|
151
|
+
>>> from edsl import QuestionMultipleChoice; q2 = QuestionMultipleChoice.example()
|
152
|
+
>>> s = Survey([q1, i, i2, q2])
|
153
|
+
>>> len(s.instruction_names_to_instructions)
|
154
|
+
2
|
155
|
+
>>> s.pseudo_indices
|
156
|
+
{'how_are_you': 0, 'intro': 0.5, 'followon_intro': 0.75, 'how_feeling': 1}
|
157
|
+
|
158
|
+
>>> from edsl import ChangeInstruction
|
159
|
+
>>> q3 = QuestionFreeText(question_text = "What is your favorite color?", question_name = "color")
|
160
|
+
>>> i_change = ChangeInstruction(drop = ["intro"])
|
161
|
+
>>> s = Survey([q1, i, q2, i_change, q3])
|
162
|
+
>>> [i.name for i in s.relevant_instructions(q1)]
|
163
|
+
[]
|
164
|
+
>>> [i.name for i in s.relevant_instructions(q2)]
|
165
|
+
['intro']
|
166
|
+
>>> [i.name for i in s.relevant_instructions(q3)]
|
167
|
+
[]
|
168
|
+
|
169
|
+
>>> i_change = ChangeInstruction(keep = ["poop"], drop = [])
|
170
|
+
>>> s = Survey([q1, i, q2, i_change])
|
171
|
+
Traceback (most recent call last):
|
172
|
+
...
|
173
|
+
ValueError: ChangeInstruction change_instruction_0 references instruction poop which does not exist.
|
174
|
+
"""
|
175
|
+
from edsl.surveys.instructions.Instruction import Instruction
|
176
|
+
from edsl.surveys.instructions.ChangeInstruction import ChangeInstruction
|
177
|
+
|
178
|
+
true_questions = []
|
179
|
+
instruction_names_to_instructions = {}
|
180
|
+
|
181
|
+
num_change_instructions = 0
|
182
|
+
pseudo_indices = {}
|
183
|
+
instructions_run_length = 0
|
184
|
+
for entry in questions_and_instructions:
|
185
|
+
if isinstance(entry, Instruction) or isinstance(entry, ChangeInstruction):
|
186
|
+
if isinstance(entry, ChangeInstruction):
|
187
|
+
entry.add_name(num_change_instructions)
|
188
|
+
num_change_instructions += 1
|
189
|
+
for prior_instruction in entry.keep + entry.drop:
|
190
|
+
if prior_instruction not in instruction_names_to_instructions:
|
191
|
+
raise ValueError(
|
192
|
+
f"ChangeInstruction {entry.name} references instruction {prior_instruction} which does not exist."
|
193
|
+
)
|
194
|
+
instructions_run_length += 1
|
195
|
+
delta = 1 - 1.0 / (2.0**instructions_run_length)
|
196
|
+
pseudo_index = (len(true_questions) - 1) + delta
|
197
|
+
entry.pseudo_index = pseudo_index
|
198
|
+
instruction_names_to_instructions[entry.name] = entry
|
199
|
+
elif isinstance(entry, QuestionBase):
|
200
|
+
pseudo_index = len(true_questions)
|
201
|
+
instructions_run_length = 0
|
202
|
+
true_questions.append(entry)
|
203
|
+
else:
|
204
|
+
raise ValueError(
|
205
|
+
f"Entry {repr(entry)} is not a QuestionBase or an Instruction."
|
206
|
+
)
|
207
|
+
|
208
|
+
pseudo_indices[entry.name] = pseudo_index
|
209
|
+
|
210
|
+
return true_questions, instruction_names_to_instructions, pseudo_indices
|
211
|
+
|
212
|
+
def relevant_instructions(self, question) -> dict:
|
220
213
|
"""This should be a dictionry with keys as question names and values as instructions that are relevant to the question.
|
221
214
|
|
222
215
|
:param question: The question to get the relevant instructions for.
|
@@ -224,13 +217,38 @@ class Survey(SurveyExportMixin, Base):
|
|
224
217
|
# Did the instruction come before the question and was it not modified by a change instruction?
|
225
218
|
|
226
219
|
"""
|
227
|
-
return
|
228
|
-
self._instruction_names_to_instructions, self.questions
|
229
|
-
)[question]
|
220
|
+
return self.relevant_instructions_dict[question]
|
230
221
|
|
231
|
-
|
232
|
-
|
233
|
-
|
222
|
+
@property
|
223
|
+
def max_pseudo_index(self) -> float:
|
224
|
+
"""Return the maximum pseudo index in the survey.
|
225
|
+
|
226
|
+
Example:
|
227
|
+
|
228
|
+
>>> s = Survey.example()
|
229
|
+
>>> s.max_pseudo_index
|
230
|
+
2
|
231
|
+
"""
|
232
|
+
if len(self.pseudo_indices) == 0:
|
233
|
+
return -1
|
234
|
+
return max(self.pseudo_indices.values())
|
235
|
+
|
236
|
+
@property
|
237
|
+
def last_item_was_instruction(self) -> bool:
|
238
|
+
"""Return whether the last item added to the survey was an instruction.
|
239
|
+
This is used to determine the pseudo-index of the next item added to the survey.
|
240
|
+
|
241
|
+
Example:
|
242
|
+
|
243
|
+
>>> s = Survey.example()
|
244
|
+
>>> s.last_item_was_instruction
|
245
|
+
False
|
246
|
+
>>> from edsl.surveys.instructions.Instruction import Instruction
|
247
|
+
>>> s = s.add_instruction(Instruction(text="Pay attention to the following questions.", name="intro"))
|
248
|
+
>>> s.last_item_was_instruction
|
249
|
+
True
|
250
|
+
"""
|
251
|
+
return isinstance(self.max_pseudo_index, float)
|
234
252
|
|
235
253
|
def add_instruction(
|
236
254
|
self, instruction: Union["Instruction", "ChangeInstruction"]
|
@@ -243,21 +261,101 @@ class Survey(SurveyExportMixin, Base):
|
|
243
261
|
>>> from edsl import Instruction
|
244
262
|
>>> i = Instruction(text="Pay attention to the following questions.", name="intro")
|
245
263
|
>>> s = Survey().add_instruction(i)
|
246
|
-
>>> s.
|
264
|
+
>>> s.instruction_names_to_instructions
|
247
265
|
{'intro': Instruction(name="intro", text="Pay attention to the following questions.")}
|
248
|
-
>>> s.
|
266
|
+
>>> s.pseudo_indices
|
249
267
|
{'intro': -0.5}
|
250
268
|
"""
|
251
|
-
|
269
|
+
import math
|
270
|
+
|
271
|
+
if instruction.name in self.instruction_names_to_instructions:
|
272
|
+
raise SurveyCreationError(
|
273
|
+
f"""Instruction name '{instruction.name}' already exists in survey. Existing names are {self.instruction_names_to_instructions.keys()}."""
|
274
|
+
)
|
275
|
+
self.instruction_names_to_instructions[instruction.name] = instruction
|
276
|
+
|
277
|
+
# was the last thing added an instruction or a question?
|
278
|
+
if self.last_item_was_instruction:
|
279
|
+
pseudo_index = (
|
280
|
+
self.max_pseudo_index
|
281
|
+
+ (math.ceil(self.max_pseudo_index) - self.max_pseudo_index) / 2
|
282
|
+
)
|
283
|
+
else:
|
284
|
+
pseudo_index = self.max_pseudo_index + 1.0 / 2.0
|
285
|
+
self.pseudo_indices[instruction.name] = pseudo_index
|
286
|
+
|
287
|
+
return self
|
252
288
|
|
253
289
|
# endregion
|
290
|
+
|
291
|
+
# region: Simulation methods
|
292
|
+
|
254
293
|
@classmethod
|
255
|
-
def random_survey(
|
256
|
-
|
294
|
+
def random_survey(self):
|
295
|
+
"""Create a random survey."""
|
296
|
+
from edsl.questions import QuestionMultipleChoice, QuestionFreeText
|
297
|
+
from random import choice
|
298
|
+
|
299
|
+
num_questions = 10
|
300
|
+
questions = []
|
301
|
+
for i in range(num_questions):
|
302
|
+
if choice([True, False]):
|
303
|
+
q = QuestionMultipleChoice(
|
304
|
+
question_text="nothing",
|
305
|
+
question_name="q_" + str(i),
|
306
|
+
question_options=list(range(3)),
|
307
|
+
)
|
308
|
+
questions.append(q)
|
309
|
+
else:
|
310
|
+
questions.append(
|
311
|
+
QuestionFreeText(
|
312
|
+
question_text="nothing", question_name="q_" + str(i)
|
313
|
+
)
|
314
|
+
)
|
315
|
+
s = Survey(questions)
|
316
|
+
start_index = choice(range(num_questions - 1))
|
317
|
+
end_index = choice(range(start_index + 1, 10))
|
318
|
+
s = s.add_rule(f"q_{start_index}", "True", f"q_{end_index}")
|
319
|
+
question_to_delete = choice(range(num_questions))
|
320
|
+
s.delete_question(f"q_{question_to_delete}")
|
321
|
+
return s
|
257
322
|
|
258
323
|
def simulate(self) -> dict:
|
259
324
|
"""Simulate the survey and return the answers."""
|
260
|
-
|
325
|
+
i = self.gen_path_through_survey()
|
326
|
+
q = next(i)
|
327
|
+
num_passes = 0
|
328
|
+
while True:
|
329
|
+
num_passes += 1
|
330
|
+
try:
|
331
|
+
answer = q._simulate_answer()
|
332
|
+
q = i.send({q.question_name: answer["answer"]})
|
333
|
+
except StopIteration:
|
334
|
+
break
|
335
|
+
|
336
|
+
if num_passes > 100:
|
337
|
+
print("Too many passes.")
|
338
|
+
raise Exception("Too many passes.")
|
339
|
+
return self.answers
|
340
|
+
|
341
|
+
def create_agent(self) -> "Agent":
|
342
|
+
"""Create an agent from the simulated answers."""
|
343
|
+
answers_dict = self.simulate()
|
344
|
+
|
345
|
+
def construct_answer_dict_function(traits: dict) -> Callable:
|
346
|
+
def func(self, question: "QuestionBase", scenario=None):
|
347
|
+
return traits.get(question.question_name, None)
|
348
|
+
|
349
|
+
return func
|
350
|
+
|
351
|
+
return Agent(traits=answers_dict).add_direct_question_answering_method(
|
352
|
+
construct_answer_dict_function(answers_dict)
|
353
|
+
)
|
354
|
+
|
355
|
+
def simulate_results(self) -> "Results":
|
356
|
+
"""Simulate the survey and return the results."""
|
357
|
+
a = self.create_agent()
|
358
|
+
return self.by([a]).run()
|
261
359
|
|
262
360
|
# endregion
|
263
361
|
|
@@ -293,19 +391,26 @@ class Survey(SurveyExportMixin, Base):
|
|
293
391
|
)
|
294
392
|
return self.question_name_to_index[question_name]
|
295
393
|
|
296
|
-
def
|
394
|
+
def get(self, question_name: str) -> QuestionBase:
|
297
395
|
"""
|
298
396
|
Return the question object given the question name.
|
299
397
|
|
300
398
|
:param question_name: The name of the question to get.
|
301
399
|
|
302
400
|
>>> s = Survey.example()
|
303
|
-
>>> s.
|
401
|
+
>>> s.get_question("q0")
|
304
402
|
Question('multiple_choice', question_name = \"""q0\""", question_text = \"""Do you like school?\""", question_options = ['yes', 'no'])
|
305
403
|
"""
|
306
404
|
if question_name not in self.question_name_to_index:
|
307
405
|
raise SurveyError(f"Question name {question_name} not found in survey.")
|
308
|
-
|
406
|
+
index = self.question_name_to_index[question_name]
|
407
|
+
return self._questions[index]
|
408
|
+
|
409
|
+
def get_question(self, question_name: str) -> QuestionBase:
|
410
|
+
"""Return the question object given the question name."""
|
411
|
+
# import warnings
|
412
|
+
# warnings.warn("survey.get_question is deprecated. Use subscript operator instead.")
|
413
|
+
return self.get(question_name)
|
309
414
|
|
310
415
|
def question_names_to_questions(self) -> dict:
|
311
416
|
"""Return a dictionary mapping question names to question attributes."""
|
@@ -338,6 +443,12 @@ class Survey(SurveyExportMixin, Base):
|
|
338
443
|
# endregion
|
339
444
|
|
340
445
|
# region: serialization methods
|
446
|
+
def __hash__(self) -> int:
|
447
|
+
"""Return a hash of the question."""
|
448
|
+
from edsl.utilities.utilities import dict_hash
|
449
|
+
|
450
|
+
return dict_hash(self.to_dict(add_edsl_version=False))
|
451
|
+
|
341
452
|
def to_dict(self, add_edsl_version=True) -> dict[str, Any]:
|
342
453
|
"""Serialize the Survey object to a dictionary.
|
343
454
|
|
@@ -345,12 +456,10 @@ class Survey(SurveyExportMixin, Base):
|
|
345
456
|
>>> s.to_dict(add_edsl_version = False).keys()
|
346
457
|
dict_keys(['questions', 'memory_plan', 'rule_collection', 'question_groups'])
|
347
458
|
"""
|
348
|
-
|
349
|
-
|
350
|
-
d = {
|
459
|
+
return {
|
351
460
|
"questions": [
|
352
461
|
q.to_dict(add_edsl_version=add_edsl_version)
|
353
|
-
for q in self.
|
462
|
+
for q in self.recombined_questions_and_instructions()
|
354
463
|
],
|
355
464
|
"memory_plan": self.memory_plan.to_dict(add_edsl_version=add_edsl_version),
|
356
465
|
"rule_collection": self.rule_collection.to_dict(
|
@@ -358,13 +467,6 @@ class Survey(SurveyExportMixin, Base):
|
|
358
467
|
),
|
359
468
|
"question_groups": self.question_groups,
|
360
469
|
}
|
361
|
-
if self.questions_to_randomize != []:
|
362
|
-
d["questions_to_randomize"] = self.questions_to_randomize
|
363
|
-
|
364
|
-
if add_edsl_version:
|
365
|
-
d["edsl_version"] = __version__
|
366
|
-
d["edsl_class_name"] = "Survey"
|
367
|
-
return d
|
368
470
|
|
369
471
|
@classmethod
|
370
472
|
@remove_edsl_version
|
@@ -387,8 +489,6 @@ class Survey(SurveyExportMixin, Base):
|
|
387
489
|
"""
|
388
490
|
|
389
491
|
def get_class(pass_dict):
|
390
|
-
from edsl.questions.QuestionBase import QuestionBase
|
391
|
-
|
392
492
|
if (class_name := pass_dict.get("edsl_class_name")) == "QuestionBase":
|
393
493
|
return QuestionBase
|
394
494
|
elif class_name == "Instruction":
|
@@ -408,16 +508,11 @@ class Survey(SurveyExportMixin, Base):
|
|
408
508
|
get_class(q_dict).from_dict(q_dict) for q_dict in data["questions"]
|
409
509
|
]
|
410
510
|
memory_plan = MemoryPlan.from_dict(data["memory_plan"])
|
411
|
-
if "questions_to_randomize" in data:
|
412
|
-
questions_to_randomize = data["questions_to_randomize"]
|
413
|
-
else:
|
414
|
-
questions_to_randomize = None
|
415
511
|
survey = cls(
|
416
512
|
questions=questions,
|
417
513
|
memory_plan=memory_plan,
|
418
514
|
rule_collection=RuleCollection.from_dict(data["rule_collection"]),
|
419
515
|
question_groups=data["question_groups"],
|
420
|
-
questions_to_randomize=questions_to_randomize,
|
421
516
|
)
|
422
517
|
return survey
|
423
518
|
|
@@ -505,16 +600,27 @@ class Survey(SurveyExportMixin, Base):
|
|
505
600
|
|
506
601
|
return Survey(questions=self.questions + other.questions)
|
507
602
|
|
508
|
-
def move_question(self, identifier: Union[str, int], new_index: int)
|
509
|
-
|
510
|
-
|
511
|
-
|
512
|
-
|
513
|
-
|
514
|
-
|
515
|
-
|
516
|
-
|
517
|
-
|
603
|
+
def move_question(self, identifier: Union[str, int], new_index: int):
|
604
|
+
if isinstance(identifier, str):
|
605
|
+
if identifier not in self.question_names:
|
606
|
+
raise SurveyError(
|
607
|
+
f"Question name '{identifier}' does not exist in the survey."
|
608
|
+
)
|
609
|
+
index = self.question_name_to_index[identifier]
|
610
|
+
elif isinstance(identifier, int):
|
611
|
+
if identifier < 0 or identifier >= len(self.questions):
|
612
|
+
raise SurveyError(f"Index {identifier} is out of range.")
|
613
|
+
index = identifier
|
614
|
+
else:
|
615
|
+
raise SurveyError(
|
616
|
+
"Identifier must be either a string (question name) or an integer (question index)."
|
617
|
+
)
|
618
|
+
|
619
|
+
moving_question = self._questions[index]
|
620
|
+
|
621
|
+
new_survey = self.delete_question(index)
|
622
|
+
new_survey.add_question(moving_question, new_index)
|
623
|
+
return new_survey
|
518
624
|
|
519
625
|
def delete_question(self, identifier: Union[str, int]) -> Survey:
|
520
626
|
"""
|
@@ -534,7 +640,54 @@ class Survey(SurveyExportMixin, Base):
|
|
534
640
|
>>> len(s.questions)
|
535
641
|
0
|
536
642
|
"""
|
537
|
-
|
643
|
+
if isinstance(identifier, str):
|
644
|
+
if identifier not in self.question_names:
|
645
|
+
raise SurveyError(
|
646
|
+
f"Question name '{identifier}' does not exist in the survey."
|
647
|
+
)
|
648
|
+
index = self.question_name_to_index[identifier]
|
649
|
+
elif isinstance(identifier, int):
|
650
|
+
if identifier < 0 or identifier >= len(self.questions):
|
651
|
+
raise SurveyError(f"Index {identifier} is out of range.")
|
652
|
+
index = identifier
|
653
|
+
else:
|
654
|
+
raise SurveyError(
|
655
|
+
"Identifier must be either a string (question name) or an integer (question index)."
|
656
|
+
)
|
657
|
+
|
658
|
+
# Remove the question
|
659
|
+
deleted_question = self._questions.pop(index)
|
660
|
+
del self.pseudo_indices[deleted_question.question_name]
|
661
|
+
|
662
|
+
# Update indices
|
663
|
+
for question_name, old_index in self.pseudo_indices.items():
|
664
|
+
if old_index > index:
|
665
|
+
self.pseudo_indices[question_name] = old_index - 1
|
666
|
+
|
667
|
+
# Update rules
|
668
|
+
new_rule_collection = RuleCollection()
|
669
|
+
for rule in self.rule_collection:
|
670
|
+
if rule.current_q == index:
|
671
|
+
continue # Remove rules associated with the deleted question
|
672
|
+
if rule.current_q > index:
|
673
|
+
rule.current_q -= 1
|
674
|
+
if rule.next_q > index:
|
675
|
+
rule.next_q -= 1
|
676
|
+
|
677
|
+
if rule.next_q == index:
|
678
|
+
if index == len(self.questions):
|
679
|
+
rule.next_q = EndOfSurvey
|
680
|
+
else:
|
681
|
+
rule.next_q = index
|
682
|
+
|
683
|
+
new_rule_collection.add_rule(rule)
|
684
|
+
self.rule_collection = new_rule_collection
|
685
|
+
|
686
|
+
# Update memory plan if it exists
|
687
|
+
if hasattr(self, "memory_plan"):
|
688
|
+
self.memory_plan.remove_question(deleted_question.question_name)
|
689
|
+
|
690
|
+
return self
|
538
691
|
|
539
692
|
def add_question(
|
540
693
|
self, question: QuestionBase, index: Optional[int] = None
|
@@ -558,17 +711,81 @@ class Survey(SurveyExportMixin, Base):
|
|
558
711
|
edsl.exceptions.surveys.SurveyCreationError: Question name 'q0' already exists in survey. Existing names are ['q0'].
|
559
712
|
...
|
560
713
|
"""
|
561
|
-
|
714
|
+
if question.question_name in self.question_names:
|
715
|
+
raise SurveyCreationError(
|
716
|
+
f"""Question name '{question.question_name}' already exists in survey. Existing names are {self.question_names}."""
|
717
|
+
)
|
718
|
+
if index is None:
|
719
|
+
index = len(self.questions)
|
562
720
|
|
563
|
-
|
721
|
+
if index > len(self.questions):
|
722
|
+
raise SurveyCreationError(
|
723
|
+
f"Index {index} is greater than the number of questions in the survey."
|
724
|
+
)
|
725
|
+
if index < 0:
|
726
|
+
raise SurveyCreationError(f"Index {index} is less than 0.")
|
727
|
+
|
728
|
+
interior_insertion = index != len(self.questions)
|
729
|
+
|
730
|
+
# index = len(self.questions)
|
731
|
+
# TODO: This is a bit ugly because the user
|
732
|
+
# doesn't "know" about _questions - it's generated by the
|
733
|
+
# descriptor.
|
734
|
+
self._questions.insert(index, question)
|
735
|
+
|
736
|
+
if interior_insertion:
|
737
|
+
for question_name, old_index in self.pseudo_indices.items():
|
738
|
+
if old_index >= index:
|
739
|
+
self.pseudo_indices[question_name] = old_index + 1
|
740
|
+
|
741
|
+
self.pseudo_indices[question.question_name] = index
|
742
|
+
|
743
|
+
## Re-do question_name to index - this is done automatically
|
744
|
+
# for question_name, old_index in self.question_name_to_index.items():
|
745
|
+
# if old_index >= index:
|
746
|
+
# self.question_name_to_index[question_name] = old_index + 1
|
747
|
+
|
748
|
+
## Need to re-do the rule collection and the indices of the questions
|
749
|
+
|
750
|
+
## If a rule is before the insertion index and next_q is also before the insertion index, no change needed.
|
751
|
+
## If the rule is before the insertion index but next_q is after the insertion index, increment the next_q by 1
|
752
|
+
## If the rule is after the insertion index, increment the current_q by 1 and the next_q by 1
|
753
|
+
|
754
|
+
# using index + 1 presumes there is a next question
|
755
|
+
if interior_insertion:
|
756
|
+
for rule in self.rule_collection:
|
757
|
+
if rule.current_q >= index:
|
758
|
+
rule.current_q += 1
|
759
|
+
if rule.next_q >= index:
|
760
|
+
rule.next_q += 1
|
761
|
+
|
762
|
+
# add a new rule
|
763
|
+
self.rule_collection.add_rule(
|
764
|
+
Rule(
|
765
|
+
current_q=index,
|
766
|
+
expression="True",
|
767
|
+
next_q=index + 1,
|
768
|
+
question_name_to_index=self.question_name_to_index,
|
769
|
+
priority=RulePriority.DEFAULT.value,
|
770
|
+
)
|
771
|
+
)
|
772
|
+
|
773
|
+
# a question might be added before the memory plan is created
|
774
|
+
# it's ok because the memory plan will be updated when it is created
|
775
|
+
if hasattr(self, "memory_plan"):
|
776
|
+
self.memory_plan.add_question(question)
|
777
|
+
|
778
|
+
return self
|
779
|
+
|
780
|
+
def recombined_questions_and_instructions(
|
564
781
|
self,
|
565
782
|
) -> list[Union[QuestionBase, "Instruction"]]:
|
566
783
|
"""Return a list of questions and instructions sorted by pseudo index."""
|
567
784
|
questions_and_instructions = self._questions + list(
|
568
|
-
self.
|
785
|
+
self.instruction_names_to_instructions.values()
|
569
786
|
)
|
570
787
|
return sorted(
|
571
|
-
questions_and_instructions, key=lambda x: self.
|
788
|
+
questions_and_instructions, key=lambda x: self.pseudo_indices[x.name]
|
572
789
|
)
|
573
790
|
|
574
791
|
# endregion
|
@@ -580,7 +797,7 @@ class Survey(SurveyExportMixin, Base):
|
|
580
797
|
>>> s = Survey.example().set_full_memory_mode()
|
581
798
|
|
582
799
|
"""
|
583
|
-
|
800
|
+
self._set_memory_plan(lambda i: self.question_names[:i])
|
584
801
|
return self
|
585
802
|
|
586
803
|
def set_lagged_memory(self, lags: int) -> Survey:
|
@@ -588,12 +805,10 @@ class Survey(SurveyExportMixin, Base):
|
|
588
805
|
|
589
806
|
The agent should remember the answers to the questions in the survey from the previous lags.
|
590
807
|
"""
|
591
|
-
|
592
|
-
lambda i: self.question_names[max(0, i - lags) : i]
|
593
|
-
)
|
808
|
+
self._set_memory_plan(lambda i: self.question_names[max(0, i - lags) : i])
|
594
809
|
return self
|
595
810
|
|
596
|
-
def _set_memory_plan(self, prior_questions_func: Callable)
|
811
|
+
def _set_memory_plan(self, prior_questions_func: Callable):
|
597
812
|
"""Set memory plan based on a provided function determining prior questions.
|
598
813
|
|
599
814
|
:param prior_questions_func: A function that takes the index of the current question and returns a list of prior questions to remember.
|
@@ -602,7 +817,11 @@ class Survey(SurveyExportMixin, Base):
|
|
602
817
|
>>> s._set_memory_plan(lambda i: s.question_names[:i])
|
603
818
|
|
604
819
|
"""
|
605
|
-
|
820
|
+
for i, question_name in enumerate(self.question_names):
|
821
|
+
self.memory_plan.add_memory_collection(
|
822
|
+
focal_question=question_name,
|
823
|
+
prior_questions=prior_questions_func(i),
|
824
|
+
)
|
606
825
|
|
607
826
|
def add_targeted_memory(
|
608
827
|
self,
|
@@ -622,10 +841,20 @@ class Survey(SurveyExportMixin, Base):
|
|
622
841
|
|
623
842
|
The agent should also remember the answers to prior_questions listed in prior_questions.
|
624
843
|
"""
|
625
|
-
|
626
|
-
focal_question
|
844
|
+
focal_question_name = self.question_names[
|
845
|
+
self._get_question_index(focal_question)
|
846
|
+
]
|
847
|
+
prior_question_name = self.question_names[
|
848
|
+
self._get_question_index(prior_question)
|
849
|
+
]
|
850
|
+
|
851
|
+
self.memory_plan.add_single_memory(
|
852
|
+
focal_question=focal_question_name,
|
853
|
+
prior_question=prior_question_name,
|
627
854
|
)
|
628
855
|
|
856
|
+
return self
|
857
|
+
|
629
858
|
def add_memory_collection(
|
630
859
|
self,
|
631
860
|
focal_question: Union[QuestionBase, str],
|
@@ -644,9 +873,23 @@ class Survey(SurveyExportMixin, Base):
|
|
644
873
|
>>> s.memory_plan
|
645
874
|
{'q2': Memory(prior_questions=['q0', 'q1'])}
|
646
875
|
"""
|
647
|
-
|
648
|
-
focal_question
|
876
|
+
focal_question_name = self.question_names[
|
877
|
+
self._get_question_index(focal_question)
|
878
|
+
]
|
879
|
+
|
880
|
+
prior_question_names = [
|
881
|
+
self.question_names[self._get_question_index(prior_question)]
|
882
|
+
for prior_question in prior_questions
|
883
|
+
]
|
884
|
+
|
885
|
+
self.memory_plan.add_memory_collection(
|
886
|
+
focal_question=focal_question_name, prior_questions=prior_question_names
|
649
887
|
)
|
888
|
+
return self
|
889
|
+
|
890
|
+
# endregion
|
891
|
+
# endregion
|
892
|
+
# endregion
|
650
893
|
|
651
894
|
# region: Question groups
|
652
895
|
def add_question_group(
|
@@ -741,9 +984,16 @@ class Survey(SurveyExportMixin, Base):
|
|
741
984
|
|
742
985
|
>>> s = Survey.example()
|
743
986
|
>>> s.show_rules()
|
744
|
-
|
987
|
+
┏━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━┓
|
988
|
+
┃ current_q ┃ expression ┃ next_q ┃ priority ┃ before_rule ┃
|
989
|
+
┡━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━┩
|
990
|
+
│ 0 │ True │ 1 │ -1 │ False │
|
991
|
+
│ 0 │ q0 == 'yes' │ 2 │ 0 │ False │
|
992
|
+
│ 1 │ True │ 2 │ -1 │ False │
|
993
|
+
│ 2 │ True │ 3 │ -1 │ False │
|
994
|
+
└───────────┴─────────────┴────────┴──────────┴─────────────┘
|
745
995
|
"""
|
746
|
-
|
996
|
+
self.rule_collection.show_rules()
|
747
997
|
|
748
998
|
def add_stop_rule(
|
749
999
|
self, question: Union[QuestionBase, str], expression: str
|
@@ -773,15 +1023,41 @@ class Survey(SurveyExportMixin, Base):
|
|
773
1023
|
edsl.exceptions.surveys.SurveyCreationError: The expression contains '<>', which is not allowed. You probably mean '!='.
|
774
1024
|
...
|
775
1025
|
"""
|
776
|
-
|
1026
|
+
expression = ValidatedString(expression)
|
1027
|
+
prior_question_appears = False
|
1028
|
+
for prior_question in self.questions:
|
1029
|
+
if prior_question.question_name in expression:
|
1030
|
+
prior_question_appears = True
|
1031
|
+
|
1032
|
+
if not prior_question_appears:
|
1033
|
+
import warnings
|
1034
|
+
|
1035
|
+
warnings.warn(
|
1036
|
+
f"The expression {expression} does not contain any prior question names. This is probably a mistake."
|
1037
|
+
)
|
1038
|
+
self.add_rule(question, expression, EndOfSurvey)
|
1039
|
+
return self
|
777
1040
|
|
778
1041
|
def clear_non_default_rules(self) -> Survey:
|
779
1042
|
"""Remove all non-default rules from the survey.
|
780
1043
|
|
781
1044
|
>>> Survey.example().show_rules()
|
782
|
-
|
1045
|
+
┏━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━┓
|
1046
|
+
┃ current_q ┃ expression ┃ next_q ┃ priority ┃ before_rule ┃
|
1047
|
+
┡━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━┩
|
1048
|
+
│ 0 │ True │ 1 │ -1 │ False │
|
1049
|
+
│ 0 │ q0 == 'yes' │ 2 │ 0 │ False │
|
1050
|
+
│ 1 │ True │ 2 │ -1 │ False │
|
1051
|
+
│ 2 │ True │ 3 │ -1 │ False │
|
1052
|
+
└───────────┴─────────────┴────────┴──────────┴─────────────┘
|
783
1053
|
>>> Survey.example().clear_non_default_rules().show_rules()
|
784
|
-
|
1054
|
+
┏━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━┓
|
1055
|
+
┃ current_q ┃ expression ┃ next_q ┃ priority ┃ before_rule ┃
|
1056
|
+
┡━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━┩
|
1057
|
+
│ 0 │ True │ 1 │ -1 │ False │
|
1058
|
+
│ 1 │ True │ 2 │ -1 │ False │
|
1059
|
+
│ 2 │ True │ 3 │ -1 │ False │
|
1060
|
+
└───────────┴────────────┴────────┴──────────┴─────────────┘
|
785
1061
|
"""
|
786
1062
|
s = Survey()
|
787
1063
|
for question in self.questions:
|
@@ -812,9 +1088,38 @@ class Survey(SurveyExportMixin, Base):
|
|
812
1088
|
|
813
1089
|
"""
|
814
1090
|
question_index = self._get_question_index(question)
|
815
|
-
|
816
|
-
|
1091
|
+
self._add_rule(question, expression, question_index + 1, before_rule=True)
|
1092
|
+
return self
|
1093
|
+
|
1094
|
+
def _get_new_rule_priority(
|
1095
|
+
self, question_index: int, before_rule: bool = False
|
1096
|
+
) -> int:
|
1097
|
+
"""Return the priority for the new rule.
|
1098
|
+
|
1099
|
+
:param question_index: The index of the question to add the rule to.
|
1100
|
+
:param before_rule: Whether the rule is evaluated before the question is answered.
|
1101
|
+
|
1102
|
+
>>> s = Survey.example()
|
1103
|
+
>>> s._get_new_rule_priority(0)
|
1104
|
+
1
|
1105
|
+
"""
|
1106
|
+
current_priorities = [
|
1107
|
+
rule.priority
|
1108
|
+
for rule in self.rule_collection.applicable_rules(
|
1109
|
+
question_index, before_rule
|
1110
|
+
)
|
1111
|
+
]
|
1112
|
+
if len(current_priorities) == 0:
|
1113
|
+
return RulePriority.DEFAULT.value + 1
|
1114
|
+
|
1115
|
+
max_priority = max(current_priorities)
|
1116
|
+
# newer rules take priority over older rules
|
1117
|
+
new_priority = (
|
1118
|
+
RulePriority.DEFAULT.value
|
1119
|
+
if len(current_priorities) == 0
|
1120
|
+
else max_priority + 1
|
817
1121
|
)
|
1122
|
+
return new_priority
|
818
1123
|
|
819
1124
|
def add_rule(
|
820
1125
|
self,
|
@@ -838,10 +1143,52 @@ class Survey(SurveyExportMixin, Base):
|
|
838
1143
|
'q2'
|
839
1144
|
|
840
1145
|
"""
|
841
|
-
return
|
1146
|
+
return self._add_rule(
|
842
1147
|
question, expression, next_question, before_rule=before_rule
|
843
1148
|
)
|
844
1149
|
|
1150
|
+
def _add_rule(
|
1151
|
+
self,
|
1152
|
+
question: Union[QuestionBase, str],
|
1153
|
+
expression: str,
|
1154
|
+
next_question: Union[QuestionBase, str, int],
|
1155
|
+
before_rule: bool = False,
|
1156
|
+
) -> Survey:
|
1157
|
+
"""
|
1158
|
+
Add a rule to a Question of the Survey with the appropriate priority.
|
1159
|
+
|
1160
|
+
:param question: The question to add the rule to.
|
1161
|
+
:param expression: The expression to evaluate.
|
1162
|
+
:param next_question: The next question to go to if the rule is true.
|
1163
|
+
:param before_rule: Whether the rule is evaluated before the question is answered.
|
1164
|
+
|
1165
|
+
|
1166
|
+
- The last rule added for the question will have the highest priority.
|
1167
|
+
- If there are no rules, the rule added gets priority -1.
|
1168
|
+
"""
|
1169
|
+
question_index = self._get_question_index(question)
|
1170
|
+
|
1171
|
+
# Might not have the name of the next question yet
|
1172
|
+
if isinstance(next_question, int):
|
1173
|
+
next_question_index = next_question
|
1174
|
+
else:
|
1175
|
+
next_question_index = self._get_question_index(next_question)
|
1176
|
+
|
1177
|
+
new_priority = self._get_new_rule_priority(question_index, before_rule)
|
1178
|
+
|
1179
|
+
self.rule_collection.add_rule(
|
1180
|
+
Rule(
|
1181
|
+
current_q=question_index,
|
1182
|
+
expression=expression,
|
1183
|
+
next_q=next_question_index,
|
1184
|
+
question_name_to_index=self.question_name_to_index,
|
1185
|
+
priority=new_priority,
|
1186
|
+
before_rule=before_rule,
|
1187
|
+
)
|
1188
|
+
)
|
1189
|
+
|
1190
|
+
return self
|
1191
|
+
|
845
1192
|
# endregion
|
846
1193
|
|
847
1194
|
# region: Forward methods
|
@@ -852,26 +1199,22 @@ class Survey(SurveyExportMixin, Base):
|
|
852
1199
|
|
853
1200
|
This takes the survey and adds an Agent and a Scenario via 'by' which converts to a Jobs object:
|
854
1201
|
|
855
|
-
>>> s = Survey.example(); from edsl
|
1202
|
+
>>> s = Survey.example(); from edsl import Agent; from edsl import Scenario
|
856
1203
|
>>> s.by(Agent.example()).by(Scenario.example())
|
857
1204
|
Jobs(...)
|
858
1205
|
"""
|
859
1206
|
from edsl.jobs.Jobs import Jobs
|
860
1207
|
|
861
|
-
|
1208
|
+
job = Jobs(survey=self)
|
1209
|
+
return job.by(*args)
|
862
1210
|
|
863
1211
|
def to_jobs(self):
|
864
|
-
"""Convert the survey to a Jobs object.
|
865
|
-
>>> s = Survey.example()
|
866
|
-
>>> s.to_jobs()
|
867
|
-
Jobs(...)
|
868
|
-
"""
|
1212
|
+
"""Convert the survey to a Jobs object."""
|
869
1213
|
from edsl.jobs.Jobs import Jobs
|
870
1214
|
|
871
1215
|
return Jobs(survey=self)
|
872
1216
|
|
873
1217
|
def show_prompts(self):
|
874
|
-
"""Show the prompts for the survey."""
|
875
1218
|
return self.to_jobs().show_prompts()
|
876
1219
|
|
877
1220
|
# endregion
|
@@ -883,7 +1226,6 @@ class Survey(SurveyExportMixin, Base):
|
|
883
1226
|
model=None,
|
884
1227
|
agent=None,
|
885
1228
|
cache=None,
|
886
|
-
verbose=False,
|
887
1229
|
disable_remote_cache: bool = False,
|
888
1230
|
disable_remote_inference: bool = False,
|
889
1231
|
**kwargs,
|
@@ -899,21 +1241,19 @@ class Survey(SurveyExportMixin, Base):
|
|
899
1241
|
>>> s(period = "evening", cache = False, disable_remote_cache = True, disable_remote_inference = True).select("answer.q0").first()
|
900
1242
|
'no'
|
901
1243
|
"""
|
902
|
-
|
903
|
-
return
|
1244
|
+
job = self.get_job(model, agent, **kwargs)
|
1245
|
+
return job.run(
|
904
1246
|
cache=cache,
|
905
|
-
verbose=verbose,
|
906
1247
|
disable_remote_cache=disable_remote_cache,
|
907
1248
|
disable_remote_inference=disable_remote_inference,
|
908
1249
|
)
|
909
1250
|
|
910
1251
|
async def run_async(
|
911
1252
|
self,
|
912
|
-
model: Optional["
|
1253
|
+
model: Optional["Model"] = None,
|
913
1254
|
agent: Optional["Agent"] = None,
|
914
1255
|
cache: Optional["Cache"] = None,
|
915
1256
|
disable_remote_inference: bool = False,
|
916
|
-
disable_remote_cache: bool = False,
|
917
1257
|
**kwargs,
|
918
1258
|
):
|
919
1259
|
"""Run the survey with default model, taking the required survey as arguments.
|
@@ -923,7 +1263,7 @@ class Survey(SurveyExportMixin, Base):
|
|
923
1263
|
>>> def f(scenario, agent_traits): return "yes" if scenario["period"] == "morning" else "no"
|
924
1264
|
>>> q = QuestionFunctional(question_name = "q0", func = f)
|
925
1265
|
>>> s = Survey([q])
|
926
|
-
>>> async def test_run_async(): result = await s.run_async(period="morning", disable_remote_inference = True
|
1266
|
+
>>> async def test_run_async(): result = await s.run_async(period="morning", disable_remote_inference = True); print(result.select("answer.q0").first())
|
927
1267
|
>>> asyncio.run(test_run_async())
|
928
1268
|
yes
|
929
1269
|
>>> import asyncio
|
@@ -931,23 +1271,20 @@ class Survey(SurveyExportMixin, Base):
|
|
931
1271
|
>>> def f(scenario, agent_traits): return "yes" if scenario["period"] == "morning" else "no"
|
932
1272
|
>>> q = QuestionFunctional(question_name = "q0", func = f)
|
933
1273
|
>>> s = Survey([q])
|
934
|
-
>>> async def test_run_async(): result = await s.run_async(period="evening", disable_remote_inference = True
|
935
|
-
>>>
|
1274
|
+
>>> async def test_run_async(): result = await s.run_async(period="evening", disable_remote_inference = True); print(result.select("answer.q0").first())
|
1275
|
+
>>> asyncio.run(test_run_async())
|
936
1276
|
no
|
937
1277
|
"""
|
938
1278
|
# TODO: temp fix by creating a cache
|
939
1279
|
if cache is None:
|
940
1280
|
from edsl.data import Cache
|
1281
|
+
|
941
1282
|
c = Cache()
|
942
1283
|
else:
|
943
1284
|
c = cache
|
944
|
-
|
945
|
-
|
946
|
-
|
947
|
-
jobs: "Jobs" = self.get_job(model=model, agent=agent, **kwargs).using(c)
|
1285
|
+
jobs: "Jobs" = self.get_job(model=model, agent=agent, **kwargs)
|
948
1286
|
return await jobs.run_async(
|
949
|
-
disable_remote_inference=disable_remote_inference
|
950
|
-
disable_remote_cache=disable_remote_cache,
|
1287
|
+
cache=c, disable_remote_inference=disable_remote_inference
|
951
1288
|
)
|
952
1289
|
|
953
1290
|
def run(self, *args, **kwargs) -> "Results":
|
@@ -965,30 +1302,9 @@ class Survey(SurveyExportMixin, Base):
|
|
965
1302
|
|
966
1303
|
return Jobs(survey=self).run(*args, **kwargs)
|
967
1304
|
|
968
|
-
def using(self, obj: Union["Cache", "KeyLookup", "BucketCollection"]) -> "Jobs":
|
969
|
-
"""Turn the survey into a Job and appends the arguments to the Job."""
|
970
|
-
from edsl.jobs.Jobs import Jobs
|
971
|
-
|
972
|
-
return Jobs(survey=self).using(obj)
|
973
|
-
|
974
|
-
def duplicate(self):
|
975
|
-
"""Duplicate the survey.
|
976
|
-
|
977
|
-
>>> s = Survey.example()
|
978
|
-
>>> s2 = s.duplicate()
|
979
|
-
>>> s == s2
|
980
|
-
True
|
981
|
-
>>> s is s2
|
982
|
-
False
|
983
|
-
|
984
|
-
"""
|
985
|
-
return Survey.from_dict(self.to_dict())
|
986
|
-
|
987
1305
|
# region: Survey flow
|
988
1306
|
def next_question(
|
989
|
-
self,
|
990
|
-
current_question: Optional[Union[str, QuestionBase]] = None,
|
991
|
-
answers: Optional[dict] = None,
|
1307
|
+
self, current_question: Union[str, QuestionBase], answers: dict
|
992
1308
|
) -> Union[QuestionBase, EndOfSurvey.__class__]:
|
993
1309
|
"""
|
994
1310
|
Return the next question in a survey.
|
@@ -1007,11 +1323,8 @@ class Survey(SurveyExportMixin, Base):
|
|
1007
1323
|
'q1'
|
1008
1324
|
|
1009
1325
|
"""
|
1010
|
-
if current_question is None:
|
1011
|
-
return self.questions[0]
|
1012
|
-
|
1013
1326
|
if isinstance(current_question, str):
|
1014
|
-
current_question = self.
|
1327
|
+
current_question = self.get_question(current_question)
|
1015
1328
|
|
1016
1329
|
question_index = self.question_name_to_index[current_question.question_name]
|
1017
1330
|
next_question_object = self.rule_collection.next_question(
|
@@ -1041,7 +1354,14 @@ class Survey(SurveyExportMixin, Base):
|
|
1041
1354
|
|
1042
1355
|
>>> s = Survey.example()
|
1043
1356
|
>>> s.show_rules()
|
1044
|
-
|
1357
|
+
┏━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━┓
|
1358
|
+
┃ current_q ┃ expression ┃ next_q ┃ priority ┃ before_rule ┃
|
1359
|
+
┡━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━┩
|
1360
|
+
│ 0 │ True │ 1 │ -1 │ False │
|
1361
|
+
│ 0 │ q0 == 'yes' │ 2 │ 0 │ False │
|
1362
|
+
│ 1 │ True │ 2 │ -1 │ False │
|
1363
|
+
│ 2 │ True │ 3 │ -1 │ False │
|
1364
|
+
└───────────┴─────────────┴────────┴──────────┴─────────────┘
|
1045
1365
|
|
1046
1366
|
Note that q0 has a rule that if the answer is 'yes', the next question is q2. If the answer is 'no', the next question is q1.
|
1047
1367
|
|
@@ -1070,6 +1390,7 @@ class Survey(SurveyExportMixin, Base):
|
|
1070
1390
|
question = self.next_question(question, self.answers)
|
1071
1391
|
|
1072
1392
|
while not question == EndOfSurvey:
|
1393
|
+
# breakpoint()
|
1073
1394
|
answer = yield question
|
1074
1395
|
self.answers.update(answer)
|
1075
1396
|
# print(f"Answers: {self.answers}")
|
@@ -1078,6 +1399,69 @@ class Survey(SurveyExportMixin, Base):
|
|
1078
1399
|
|
1079
1400
|
# endregion
|
1080
1401
|
|
1402
|
+
# regions: DAG construction
|
1403
|
+
def textify(self, index_dag: DAG) -> DAG:
|
1404
|
+
"""Convert the DAG of question indices to a DAG of question names.
|
1405
|
+
|
1406
|
+
:param index_dag: The DAG of question indices.
|
1407
|
+
|
1408
|
+
Example:
|
1409
|
+
|
1410
|
+
>>> s = Survey.example()
|
1411
|
+
>>> d = s.dag()
|
1412
|
+
>>> d
|
1413
|
+
{1: {0}, 2: {0}}
|
1414
|
+
>>> s.textify(d)
|
1415
|
+
{'q1': {'q0'}, 'q2': {'q0'}}
|
1416
|
+
"""
|
1417
|
+
|
1418
|
+
def get_name(index: int):
|
1419
|
+
"""Return the name of the question given the index."""
|
1420
|
+
if index >= len(self.questions):
|
1421
|
+
return EndOfSurvey
|
1422
|
+
try:
|
1423
|
+
return self.questions[index].question_name
|
1424
|
+
except IndexError:
|
1425
|
+
print(
|
1426
|
+
f"The index is {index} but the length of the questions is {len(self.questions)}"
|
1427
|
+
)
|
1428
|
+
raise SurveyError
|
1429
|
+
|
1430
|
+
try:
|
1431
|
+
text_dag = {}
|
1432
|
+
for child_index, parent_indices in index_dag.items():
|
1433
|
+
parent_names = {get_name(index) for index in parent_indices}
|
1434
|
+
child_name = get_name(child_index)
|
1435
|
+
text_dag[child_name] = parent_names
|
1436
|
+
return text_dag
|
1437
|
+
except IndexError:
|
1438
|
+
raise
|
1439
|
+
|
1440
|
+
@property
|
1441
|
+
def piping_dag(self) -> DAG:
|
1442
|
+
"""Figures out the DAG of piping dependencies.
|
1443
|
+
|
1444
|
+
>>> from edsl import QuestionFreeText
|
1445
|
+
>>> q0 = QuestionFreeText(question_text="Here is a question", question_name="q0")
|
1446
|
+
>>> q1 = QuestionFreeText(question_text="You previously answered {{ q0 }}---how do you feel now?", question_name="q1")
|
1447
|
+
>>> s = Survey([q0, q1])
|
1448
|
+
>>> s.piping_dag
|
1449
|
+
{1: {0}}
|
1450
|
+
"""
|
1451
|
+
d = {}
|
1452
|
+
for question_name, depenencies in self.parameters_by_question.items():
|
1453
|
+
if depenencies:
|
1454
|
+
question_index = self.question_name_to_index[question_name]
|
1455
|
+
for dependency in depenencies:
|
1456
|
+
if dependency not in self.question_name_to_index:
|
1457
|
+
pass
|
1458
|
+
else:
|
1459
|
+
dependency_index = self.question_name_to_index[dependency]
|
1460
|
+
if question_index not in d:
|
1461
|
+
d[question_index] = set()
|
1462
|
+
d[question_index].add(dependency_index)
|
1463
|
+
return d
|
1464
|
+
|
1081
1465
|
def dag(self, textify: bool = False) -> DAG:
|
1082
1466
|
"""Return the DAG of the survey, which reflects both skip-logic and memory.
|
1083
1467
|
|
@@ -1089,9 +1473,14 @@ class Survey(SurveyExportMixin, Base):
|
|
1089
1473
|
{1: {0}, 2: {0}}
|
1090
1474
|
|
1091
1475
|
"""
|
1092
|
-
|
1093
|
-
|
1094
|
-
|
1476
|
+
memory_dag = self.memory_plan.dag
|
1477
|
+
rule_dag = self.rule_collection.dag
|
1478
|
+
piping_dag = self.piping_dag
|
1479
|
+
if textify:
|
1480
|
+
memory_dag = DAG(self.textify(memory_dag))
|
1481
|
+
rule_dag = DAG(self.textify(rule_dag))
|
1482
|
+
piping_dag = DAG(self.textify(piping_dag))
|
1483
|
+
return memory_dag + rule_dag + piping_dag
|
1095
1484
|
|
1096
1485
|
###################
|
1097
1486
|
# DUNDER METHODS
|
@@ -1120,18 +1509,77 @@ class Survey(SurveyExportMixin, Base):
|
|
1120
1509
|
elif isinstance(index, str):
|
1121
1510
|
return getattr(self, index)
|
1122
1511
|
|
1123
|
-
|
1124
|
-
|
1125
|
-
|
1512
|
+
def _diff(self, other):
|
1513
|
+
"""Used for debugging. Print out the differences between two surveys."""
|
1514
|
+
from rich import print
|
1515
|
+
|
1516
|
+
for key, value in self.to_dict().items():
|
1517
|
+
if value != other.to_dict()[key]:
|
1518
|
+
print(f"Key: {key}")
|
1519
|
+
print("\n")
|
1520
|
+
print(f"Self: {value}")
|
1521
|
+
print("\n")
|
1522
|
+
print(f"Other: {other.to_dict()[key]}")
|
1523
|
+
print("\n\n")
|
1524
|
+
|
1525
|
+
def __eq__(self, other) -> bool:
|
1526
|
+
"""Return True if the two surveys have the same to_dict.
|
1527
|
+
|
1528
|
+
:param other: The other survey to compare to.
|
1529
|
+
|
1530
|
+
>>> s = Survey.example()
|
1531
|
+
>>> s == s
|
1532
|
+
True
|
1533
|
+
|
1534
|
+
>>> s == "poop"
|
1535
|
+
False
|
1536
|
+
|
1537
|
+
"""
|
1538
|
+
if not isinstance(other, Survey):
|
1539
|
+
return False
|
1540
|
+
return self.to_dict() == other.to_dict()
|
1541
|
+
|
1542
|
+
@classmethod
|
1543
|
+
def from_qsf(
|
1544
|
+
cls, qsf_file: Optional[str] = None, url: Optional[str] = None
|
1545
|
+
) -> Survey:
|
1546
|
+
"""Create a Survey object from a Qualtrics QSF file."""
|
1126
1547
|
|
1127
|
-
|
1128
|
-
|
1129
|
-
|
1130
|
-
|
1131
|
-
|
1132
|
-
|
1133
|
-
|
1134
|
-
|
1548
|
+
if url and qsf_file:
|
1549
|
+
raise ValueError("Only one of url or qsf_file can be provided.")
|
1550
|
+
|
1551
|
+
if (not url) and (not qsf_file):
|
1552
|
+
raise ValueError("Either url or qsf_file must be provided.")
|
1553
|
+
|
1554
|
+
if url:
|
1555
|
+
response = requests.get(url)
|
1556
|
+
response.raise_for_status() # Ensure the request was successful
|
1557
|
+
|
1558
|
+
# Save the Excel file to a temporary file
|
1559
|
+
with tempfile.NamedTemporaryFile(suffix=".qsf", delete=False) as temp_file:
|
1560
|
+
temp_file.write(response.content)
|
1561
|
+
qsf_file = temp_file.name
|
1562
|
+
|
1563
|
+
from edsl.surveys.SurveyQualtricsImport import SurveyQualtricsImport
|
1564
|
+
|
1565
|
+
so = SurveyQualtricsImport(qsf_file)
|
1566
|
+
return so.create_survey()
|
1567
|
+
|
1568
|
+
# region: Display methods
|
1569
|
+
def print(self):
|
1570
|
+
"""Print the survey in a rich format.
|
1571
|
+
|
1572
|
+
>>> s = Survey.example()
|
1573
|
+
>>> s.print()
|
1574
|
+
{
|
1575
|
+
"questions": [
|
1576
|
+
...
|
1577
|
+
}
|
1578
|
+
"""
|
1579
|
+
from rich import print_json
|
1580
|
+
import json
|
1581
|
+
|
1582
|
+
print_json(json.dumps(self.to_dict()))
|
1135
1583
|
|
1136
1584
|
def __repr__(self) -> str:
|
1137
1585
|
"""Return a string representation of the survey."""
|
@@ -1139,20 +1587,60 @@ class Survey(SurveyExportMixin, Base):
|
|
1139
1587
|
# questions_string = ", ".join([repr(q) for q in self._questions])
|
1140
1588
|
questions_string = ", ".join([repr(q) for q in self.raw_passed_questions or []])
|
1141
1589
|
# question_names_string = ", ".join([repr(name) for name in self.question_names])
|
1142
|
-
return f"Survey(questions=[{questions_string}], memory_plan={self.memory_plan}, rule_collection={self.rule_collection}, question_groups={self.question_groups}
|
1590
|
+
return f"Survey(questions=[{questions_string}], memory_plan={self.memory_plan}, rule_collection={self.rule_collection}, question_groups={self.question_groups})"
|
1143
1591
|
|
1144
1592
|
def _summary(self) -> dict:
|
1145
1593
|
return {
|
1146
|
-
"
|
1147
|
-
"
|
1594
|
+
"EDSL Class": "Survey",
|
1595
|
+
"Number of Questions": len(self),
|
1596
|
+
"Question Names": self.question_names,
|
1148
1597
|
}
|
1149
1598
|
|
1599
|
+
def _repr_html_(self) -> str:
|
1600
|
+
footer = f"<a href={self.__documentation__}>(docs)</a>"
|
1601
|
+
return str(self.summary(format="html")) + footer
|
1602
|
+
|
1150
1603
|
def tree(self, node_list: Optional[List[str]] = None):
|
1151
1604
|
return self.to_scenario_list().tree(node_list=node_list)
|
1152
1605
|
|
1153
1606
|
def table(self, *fields, tablefmt=None) -> Table:
|
1154
1607
|
return self.to_scenario_list().to_dataset().table(*fields, tablefmt=tablefmt)
|
1155
1608
|
|
1609
|
+
def rich_print(self) -> Table:
|
1610
|
+
"""Print the survey in a rich format.
|
1611
|
+
|
1612
|
+
>>> t = Survey.example().rich_print()
|
1613
|
+
>>> print(t) # doctest: +SKIP
|
1614
|
+
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
1615
|
+
┃ Questions ┃
|
1616
|
+
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
|
1617
|
+
│ ┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┓ │
|
1618
|
+
│ ┃ Question Name ┃ Question Type ┃ Question Text ┃ Options ┃ │
|
1619
|
+
│ ┡━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━┩ │
|
1620
|
+
│ │ q0 │ multiple_choice │ Do you like school? │ yes, no │ │
|
1621
|
+
│ └───────────────┴─────────────────┴─────────────────────┴─────────┘ │
|
1622
|
+
│ ┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ │
|
1623
|
+
│ ┃ Question Name ┃ Question Type ┃ Question Text ┃ Options ┃ │
|
1624
|
+
│ ┡━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │
|
1625
|
+
│ │ q1 │ multiple_choice │ Why not? │ killer bees in cafeteria, other │ │
|
1626
|
+
│ └───────────────┴─────────────────┴───────────────┴─────────────────────────────────┘ │
|
1627
|
+
│ ┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ │
|
1628
|
+
│ ┃ Question Name ┃ Question Type ┃ Question Text ┃ Options ┃ │
|
1629
|
+
│ ┡━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │
|
1630
|
+
│ │ q2 │ multiple_choice │ Why? │ **lack*** of killer bees in cafeteria, other │ │
|
1631
|
+
│ └───────────────┴─────────────────┴───────────────┴──────────────────────────────────────────────┘ │
|
1632
|
+
└────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
1633
|
+
"""
|
1634
|
+
from rich.table import Table
|
1635
|
+
|
1636
|
+
table = Table(show_header=True, header_style="bold magenta")
|
1637
|
+
table.add_column("Questions", style="dim")
|
1638
|
+
|
1639
|
+
for question in self._questions:
|
1640
|
+
table.add_row(question.rich_print())
|
1641
|
+
|
1642
|
+
return table
|
1643
|
+
|
1156
1644
|
# endregion
|
1157
1645
|
|
1158
1646
|
def codebook(self) -> dict[str, str]:
|
@@ -1167,6 +1655,37 @@ class Survey(SurveyExportMixin, Base):
|
|
1167
1655
|
codebook[question.question_name] = question.question_text
|
1168
1656
|
return codebook
|
1169
1657
|
|
1658
|
+
# region: Export methods
|
1659
|
+
def to_csv(self, filename: str = None):
|
1660
|
+
"""Export the survey to a CSV file.
|
1661
|
+
|
1662
|
+
:param filename: The name of the file to save the CSV to.
|
1663
|
+
|
1664
|
+
>>> s = Survey.example()
|
1665
|
+
>>> s.to_csv() # doctest: +SKIP
|
1666
|
+
index question_name question_text question_options question_type
|
1667
|
+
0 0 q0 Do you like school? [yes, no] multiple_choice
|
1668
|
+
1 1 q1 Why not? [killer bees in cafeteria, other] multiple_choice
|
1669
|
+
2 2 q2 Why? [**lack*** of killer bees in cafeteria, other] multiple_choice
|
1670
|
+
"""
|
1671
|
+
raw_data = []
|
1672
|
+
for index, question in enumerate(self._questions):
|
1673
|
+
d = {"index": index}
|
1674
|
+
question_dict = question.to_dict()
|
1675
|
+
_ = question_dict.pop("edsl_version")
|
1676
|
+
_ = question_dict.pop("edsl_class_name")
|
1677
|
+
d.update(question_dict)
|
1678
|
+
raw_data.append(d)
|
1679
|
+
from pandas import DataFrame
|
1680
|
+
|
1681
|
+
df = DataFrame(raw_data)
|
1682
|
+
if filename:
|
1683
|
+
df.to_csv(filename, index=False)
|
1684
|
+
else:
|
1685
|
+
return df
|
1686
|
+
|
1687
|
+
# endregion
|
1688
|
+
|
1170
1689
|
@classmethod
|
1171
1690
|
def example(
|
1172
1691
|
cls,
|
@@ -1225,7 +1744,7 @@ class Survey(SurveyExportMixin, Base):
|
|
1225
1744
|
|
1226
1745
|
def get_job(self, model=None, agent=None, **kwargs):
|
1227
1746
|
if model is None:
|
1228
|
-
from edsl
|
1747
|
+
from edsl import Model
|
1229
1748
|
|
1230
1749
|
model = Model()
|
1231
1750
|
|
@@ -1234,7 +1753,7 @@ class Survey(SurveyExportMixin, Base):
|
|
1234
1753
|
s = Scenario(kwargs)
|
1235
1754
|
|
1236
1755
|
if not agent:
|
1237
|
-
from edsl
|
1756
|
+
from edsl import Agent
|
1238
1757
|
|
1239
1758
|
agent = Agent()
|
1240
1759
|
|
@@ -1246,24 +1765,26 @@ def main():
|
|
1246
1765
|
|
1247
1766
|
def example_survey():
|
1248
1767
|
"""Return an example survey."""
|
1249
|
-
from edsl import QuestionMultipleChoice
|
1768
|
+
from edsl.questions.QuestionMultipleChoice import QuestionMultipleChoice
|
1769
|
+
from edsl.surveys.Survey import Survey
|
1250
1770
|
|
1251
1771
|
q0 = QuestionMultipleChoice(
|
1772
|
+
question_text="Do you like school?",
|
1773
|
+
question_options=["yes", "no"],
|
1252
1774
|
question_name="q0",
|
1253
|
-
question_text="What is the capital of France?",
|
1254
|
-
question_options=["London", "Paris", "Rome", "Boston", "I don't know"]
|
1255
1775
|
)
|
1256
|
-
q1 =
|
1776
|
+
q1 = QuestionMultipleChoice(
|
1777
|
+
question_text="Why not?",
|
1778
|
+
question_options=["killer bees in cafeteria", "other"],
|
1257
1779
|
question_name="q1",
|
1258
|
-
question_text="Name some cities in France.",
|
1259
|
-
max_list_items = 5
|
1260
1780
|
)
|
1261
|
-
q2 =
|
1781
|
+
q2 = QuestionMultipleChoice(
|
1782
|
+
question_text="Why?",
|
1783
|
+
question_options=["**lack*** of killer bees in cafeteria", "other"],
|
1262
1784
|
question_name="q2",
|
1263
|
-
question_text="What is the population of {{ q0.answer }}?"
|
1264
1785
|
)
|
1265
1786
|
s = Survey(questions=[q0, q1, q2])
|
1266
|
-
s = s.add_rule(q0, "q0 == '
|
1787
|
+
s = s.add_rule(q0, "q0 == 'yes'", q2)
|
1267
1788
|
return s
|
1268
1789
|
|
1269
1790
|
s = example_survey()
|