QuizGenerator 0.8.1__py3-none-any.whl → 0.9.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.
- QuizGenerator/contentast.py +1 -1
- QuizGenerator/generate.py +1 -1
- QuizGenerator/mixins.py +6 -2
- QuizGenerator/premade_questions/basic.py +49 -7
- QuizGenerator/premade_questions/cst463/gradient_descent/gradient_calculation.py +92 -82
- QuizGenerator/premade_questions/cst463/gradient_descent/gradient_descent_questions.py +68 -45
- QuizGenerator/premade_questions/cst463/gradient_descent/loss_calculations.py +235 -162
- QuizGenerator/premade_questions/cst463/neural-network-basics/neural_network_questions.py +51 -45
- QuizGenerator/premade_questions/cst463/tensorflow-intro/tensorflow_questions.py +212 -215
- QuizGenerator/question.py +139 -18
- {quizgenerator-0.8.1.dist-info → quizgenerator-0.9.0.dist-info}/METADATA +9 -6
- {quizgenerator-0.8.1.dist-info → quizgenerator-0.9.0.dist-info}/RECORD +15 -15
- {quizgenerator-0.8.1.dist-info → quizgenerator-0.9.0.dist-info}/WHEEL +0 -0
- {quizgenerator-0.8.1.dist-info → quizgenerator-0.9.0.dist-info}/entry_points.txt +0 -0
- {quizgenerator-0.8.1.dist-info → quizgenerator-0.9.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -19,138 +19,155 @@ log = logging.getLogger(__name__)
|
|
|
19
19
|
class LossQuestion(Question, TableQuestionMixin, BodyTemplatesMixin, abc.ABC):
|
|
20
20
|
"""Base class for loss function calculation questions."""
|
|
21
21
|
|
|
22
|
+
DEFAULT_NUM_SAMPLES = 5
|
|
23
|
+
DEFAULT_NUM_INPUT_FEATURES = 2
|
|
24
|
+
DEFAULT_VECTOR_INPUTS = False
|
|
25
|
+
|
|
22
26
|
def __init__(self, *args, **kwargs):
|
|
23
27
|
kwargs["topic"] = kwargs.get("topic", Question.Topic.ML_OPTIMIZATION)
|
|
24
28
|
super().__init__(*args, **kwargs)
|
|
25
29
|
|
|
26
|
-
self.num_samples = kwargs.get("num_samples",
|
|
30
|
+
self.num_samples = kwargs.get("num_samples", self.DEFAULT_NUM_SAMPLES)
|
|
27
31
|
self.num_samples = max(3, min(10, self.num_samples)) # Constrain to 3-10 range
|
|
28
32
|
|
|
29
|
-
self.num_input_features = kwargs.get("num_input_features",
|
|
33
|
+
self.num_input_features = kwargs.get("num_input_features", self.DEFAULT_NUM_INPUT_FEATURES)
|
|
30
34
|
self.num_input_features = max(1, min(5, self.num_input_features)) # Constrain to 1-5 features
|
|
31
|
-
self.vector_inputs = kwargs.get("vector_inputs",
|
|
35
|
+
self.vector_inputs = kwargs.get("vector_inputs", self.DEFAULT_VECTOR_INPUTS) # Whether to show inputs as vectors
|
|
32
36
|
|
|
33
37
|
# Generate sample data
|
|
34
38
|
self.data = []
|
|
35
39
|
self.individual_losses = []
|
|
36
40
|
self.overall_loss = 0.0
|
|
37
41
|
|
|
38
|
-
|
|
42
|
+
@classmethod
|
|
43
|
+
def _build_context(cls, *, rng_seed=None, **kwargs):
|
|
39
44
|
"""Generate new random data and calculate losses."""
|
|
45
|
+
context = super()._build_context(rng_seed=rng_seed, **kwargs)
|
|
46
|
+
cls._populate_context(context, **kwargs)
|
|
40
47
|
# Update configurable parameters if provided
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
# Seed RNG and generate data
|
|
49
|
-
self.rng.seed(rng_seed)
|
|
50
|
-
self._generate_data()
|
|
51
|
-
self._calculate_losses()
|
|
52
|
-
|
|
53
|
-
context = dict(kwargs)
|
|
54
|
-
context["rng_seed"] = rng_seed
|
|
48
|
+
context.num_samples = max(3, min(10, kwargs.get("num_samples", cls.DEFAULT_NUM_SAMPLES)))
|
|
49
|
+
context.num_input_features = max(1, min(5, kwargs.get("num_input_features", cls.DEFAULT_NUM_INPUT_FEATURES)))
|
|
50
|
+
context.vector_inputs = kwargs.get("vector_inputs", cls.DEFAULT_VECTOR_INPUTS)
|
|
51
|
+
|
|
52
|
+
# Generate data + losses
|
|
53
|
+
cls._generate_data(context)
|
|
54
|
+
cls._calculate_losses(context)
|
|
55
55
|
return context
|
|
56
56
|
|
|
57
|
+
@classmethod
|
|
58
|
+
def _populate_context(cls, context, **kwargs):
|
|
59
|
+
"""Hook for subclasses to add required context before data generation."""
|
|
60
|
+
return context
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
57
63
|
@abc.abstractmethod
|
|
58
|
-
def _generate_data(
|
|
64
|
+
def _generate_data(cls, context):
|
|
59
65
|
"""Generate sample data appropriate for this loss function type."""
|
|
60
66
|
pass
|
|
61
67
|
|
|
68
|
+
@classmethod
|
|
62
69
|
@abc.abstractmethod
|
|
63
|
-
def _calculate_losses(
|
|
70
|
+
def _calculate_losses(cls, context):
|
|
64
71
|
"""Calculate individual and overall losses."""
|
|
65
72
|
pass
|
|
66
73
|
|
|
74
|
+
@classmethod
|
|
67
75
|
@abc.abstractmethod
|
|
68
|
-
def _get_loss_function_name(
|
|
76
|
+
def _get_loss_function_name(cls, context) -> str:
|
|
69
77
|
"""Return the name of the loss function."""
|
|
70
78
|
pass
|
|
71
79
|
|
|
80
|
+
@classmethod
|
|
72
81
|
@abc.abstractmethod
|
|
73
|
-
def _get_loss_function_formula(
|
|
82
|
+
def _get_loss_function_formula(cls, context) -> str:
|
|
74
83
|
"""Return the LaTeX formula for the loss function."""
|
|
75
84
|
pass
|
|
76
85
|
|
|
86
|
+
@classmethod
|
|
77
87
|
@abc.abstractmethod
|
|
78
|
-
def _get_loss_function_short_name(
|
|
88
|
+
def _get_loss_function_short_name(cls, context) -> str:
|
|
79
89
|
"""Return the short name of the loss function (used in question body)."""
|
|
80
90
|
pass
|
|
81
91
|
|
|
82
|
-
|
|
92
|
+
@classmethod
|
|
93
|
+
def _build_loss_answers(cls, context) -> Tuple[List[ca.Answer], ca.Answer]:
|
|
83
94
|
answers = [
|
|
84
|
-
ca.AnswerTypes.Float(
|
|
85
|
-
for i in range(
|
|
95
|
+
ca.AnswerTypes.Float(context.individual_losses[i], label=f"Sample {i + 1} loss")
|
|
96
|
+
for i in range(context.num_samples)
|
|
86
97
|
]
|
|
87
|
-
overall = ca.AnswerTypes.Float(
|
|
98
|
+
overall = ca.AnswerTypes.Float(context.overall_loss, label="Overall loss")
|
|
88
99
|
return answers, overall
|
|
89
100
|
|
|
90
|
-
|
|
101
|
+
@classmethod
|
|
102
|
+
def _build_body(cls, context) -> Tuple[ca.Element, List[ca.Answer]]:
|
|
91
103
|
"""Build question body and collect answers."""
|
|
92
104
|
body = ca.Section()
|
|
93
105
|
answers = []
|
|
94
106
|
|
|
95
107
|
# Question description
|
|
96
108
|
body.add_element(ca.Paragraph([
|
|
97
|
-
f"Given the dataset below, calculate the {
|
|
98
|
-
f"and the overall {
|
|
109
|
+
f"Given the dataset below, calculate the {cls._get_loss_function_short_name(context)} for each sample "
|
|
110
|
+
f"and the overall {cls._get_loss_function_short_name(context)}."
|
|
99
111
|
]))
|
|
100
112
|
|
|
101
113
|
# Data table (contains individual loss answers)
|
|
102
|
-
loss_answers, overall_answer =
|
|
103
|
-
body.add_element(
|
|
114
|
+
loss_answers, overall_answer = cls._build_loss_answers(context)
|
|
115
|
+
body.add_element(cls._create_data_table(context, loss_answers))
|
|
104
116
|
answers.extend(loss_answers)
|
|
105
117
|
|
|
106
118
|
# Overall loss question
|
|
107
119
|
body.add_element(ca.Paragraph([
|
|
108
|
-
f"Overall {
|
|
120
|
+
f"Overall {cls._get_loss_function_short_name(context)}: "
|
|
109
121
|
]))
|
|
110
122
|
answers.append(overall_answer)
|
|
111
123
|
body.add_element(overall_answer)
|
|
112
124
|
|
|
113
125
|
return body, answers
|
|
114
126
|
|
|
127
|
+
@classmethod
|
|
115
128
|
@abc.abstractmethod
|
|
116
|
-
def _create_data_table(
|
|
129
|
+
def _create_data_table(cls, context, loss_answers: List[ca.Answer]) -> ca.Element:
|
|
117
130
|
"""Create the data table with answer fields."""
|
|
118
131
|
pass
|
|
119
132
|
|
|
120
|
-
|
|
133
|
+
@classmethod
|
|
134
|
+
def _build_explanation(cls, context) -> Tuple[ca.Element, List[ca.Answer]]:
|
|
121
135
|
"""Build question explanation."""
|
|
122
136
|
explanation = ca.Section()
|
|
123
137
|
|
|
124
138
|
explanation.add_element(ca.Paragraph([
|
|
125
|
-
f"To calculate the {
|
|
139
|
+
f"To calculate the {cls._get_loss_function_name(context)}, we apply the formula to each sample:"
|
|
126
140
|
]))
|
|
127
141
|
|
|
128
|
-
explanation.add_element(ca.Equation(
|
|
142
|
+
explanation.add_element(ca.Equation(cls._get_loss_function_formula(context), inline=False))
|
|
129
143
|
|
|
130
144
|
# Step-by-step calculations
|
|
131
|
-
explanation.add_element(
|
|
145
|
+
explanation.add_element(cls._create_calculation_steps(context))
|
|
132
146
|
|
|
133
147
|
# Completed table
|
|
134
148
|
explanation.add_element(ca.Paragraph(["Completed table:"]))
|
|
135
|
-
explanation.add_element(
|
|
149
|
+
explanation.add_element(cls._create_completed_table(context))
|
|
136
150
|
|
|
137
151
|
# Overall loss calculation
|
|
138
|
-
explanation.add_element(
|
|
152
|
+
explanation.add_element(cls._create_overall_loss_explanation(context))
|
|
139
153
|
|
|
140
154
|
return explanation, []
|
|
141
155
|
|
|
156
|
+
@classmethod
|
|
142
157
|
@abc.abstractmethod
|
|
143
|
-
def _create_calculation_steps(
|
|
158
|
+
def _create_calculation_steps(cls, context) -> ca.Element:
|
|
144
159
|
"""Create step-by-step calculation explanations."""
|
|
145
160
|
pass
|
|
146
161
|
|
|
162
|
+
@classmethod
|
|
147
163
|
@abc.abstractmethod
|
|
148
|
-
def _create_completed_table(
|
|
164
|
+
def _create_completed_table(cls, context) -> ca.Element:
|
|
149
165
|
"""Create the completed table with all values filled in."""
|
|
150
166
|
pass
|
|
151
167
|
|
|
168
|
+
@classmethod
|
|
152
169
|
@abc.abstractmethod
|
|
153
|
-
def _create_overall_loss_explanation(
|
|
170
|
+
def _create_overall_loss_explanation(cls, context) -> ca.Element:
|
|
154
171
|
"""Create explanation for overall loss calculation."""
|
|
155
172
|
pass
|
|
156
173
|
|
|
@@ -159,47 +176,67 @@ class LossQuestion(Question, TableQuestionMixin, BodyTemplatesMixin, abc.ABC):
|
|
|
159
176
|
class LossQuestion_Linear(LossQuestion):
|
|
160
177
|
"""Linear regression with Mean Squared Error (MSE) loss."""
|
|
161
178
|
|
|
179
|
+
DEFAULT_NUM_OUTPUT_VARS = 1
|
|
180
|
+
|
|
162
181
|
def __init__(self, *args, **kwargs):
|
|
163
|
-
self.num_output_vars = kwargs.get("num_output_vars",
|
|
182
|
+
self.num_output_vars = kwargs.get("num_output_vars", self.DEFAULT_NUM_OUTPUT_VARS)
|
|
164
183
|
self.num_output_vars = max(1, min(5, self.num_output_vars)) # Constrain to 1-5 range
|
|
165
184
|
super().__init__(*args, **kwargs)
|
|
166
185
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
self.num_output_vars = max(1, min(5, kwargs.get("num_output_vars", self.num_output_vars)))
|
|
186
|
+
@classmethod
|
|
187
|
+
def _build_context(cls, *, rng_seed=None, **kwargs):
|
|
170
188
|
return super()._build_context(rng_seed=rng_seed, **kwargs)
|
|
171
189
|
|
|
172
|
-
|
|
190
|
+
@classmethod
|
|
191
|
+
def _populate_context(cls, context, **kwargs):
|
|
192
|
+
context.num_output_vars = max(
|
|
193
|
+
1,
|
|
194
|
+
min(5, kwargs.get("num_output_vars", cls.DEFAULT_NUM_OUTPUT_VARS))
|
|
195
|
+
)
|
|
196
|
+
return context
|
|
197
|
+
|
|
198
|
+
@classmethod
|
|
199
|
+
def _generate_data(cls, context):
|
|
173
200
|
"""Generate regression data with continuous target values."""
|
|
174
|
-
|
|
201
|
+
context.data = []
|
|
175
202
|
|
|
176
|
-
for
|
|
203
|
+
for _ in range(context.num_samples):
|
|
177
204
|
sample = {}
|
|
178
205
|
|
|
179
206
|
# Generate input features (rounded to 2 decimal places)
|
|
180
|
-
sample['inputs'] = [
|
|
207
|
+
sample['inputs'] = [
|
|
208
|
+
round(context.rng.uniform(-100, 100), 2)
|
|
209
|
+
for _ in range(context.num_input_features)
|
|
210
|
+
]
|
|
181
211
|
|
|
182
212
|
# Generate true values (y) - multiple outputs if specified (rounded to 2 decimal places)
|
|
183
|
-
if
|
|
184
|
-
sample['true_values'] = round(
|
|
213
|
+
if context.num_output_vars == 1:
|
|
214
|
+
sample['true_values'] = round(context.rng.uniform(-100, 100), 2)
|
|
185
215
|
else:
|
|
186
|
-
sample['true_values'] = [
|
|
216
|
+
sample['true_values'] = [
|
|
217
|
+
round(context.rng.uniform(-100, 100), 2)
|
|
218
|
+
for _ in range(context.num_output_vars)
|
|
219
|
+
]
|
|
187
220
|
|
|
188
221
|
# Generate predictions (p) - multiple outputs if specified (rounded to 2 decimal places)
|
|
189
|
-
if
|
|
190
|
-
sample['predictions'] = round(
|
|
222
|
+
if context.num_output_vars == 1:
|
|
223
|
+
sample['predictions'] = round(context.rng.uniform(-100, 100), 2)
|
|
191
224
|
else:
|
|
192
|
-
sample['predictions'] = [
|
|
225
|
+
sample['predictions'] = [
|
|
226
|
+
round(context.rng.uniform(-100, 100), 2)
|
|
227
|
+
for _ in range(context.num_output_vars)
|
|
228
|
+
]
|
|
193
229
|
|
|
194
|
-
|
|
230
|
+
context.data.append(sample)
|
|
195
231
|
|
|
196
|
-
|
|
232
|
+
@classmethod
|
|
233
|
+
def _calculate_losses(cls, context):
|
|
197
234
|
"""Calculate MSE for each sample and overall."""
|
|
198
|
-
|
|
235
|
+
context.individual_losses = []
|
|
199
236
|
total_loss = 0.0
|
|
200
237
|
|
|
201
|
-
for sample in
|
|
202
|
-
if
|
|
238
|
+
for sample in context.data:
|
|
239
|
+
if context.num_output_vars == 1:
|
|
203
240
|
# Single output MSE: (y - p)^2
|
|
204
241
|
loss = (sample['true_values'] - sample['predictions']) ** 2
|
|
205
242
|
else:
|
|
@@ -209,40 +246,43 @@ class LossQuestion_Linear(LossQuestion):
|
|
|
209
246
|
for y, p in zip(sample['true_values'], sample['predictions'])
|
|
210
247
|
)
|
|
211
248
|
|
|
212
|
-
|
|
249
|
+
context.individual_losses.append(loss)
|
|
213
250
|
total_loss += loss
|
|
214
251
|
|
|
215
252
|
# Overall MSE is average of individual losses
|
|
216
|
-
|
|
253
|
+
context.overall_loss = total_loss / context.num_samples
|
|
217
254
|
|
|
218
|
-
|
|
255
|
+
@classmethod
|
|
256
|
+
def _get_loss_function_name(cls, context) -> str:
|
|
219
257
|
return "Mean Squared Error (MSE)"
|
|
220
258
|
|
|
221
|
-
|
|
259
|
+
@classmethod
|
|
260
|
+
def _get_loss_function_short_name(cls, context) -> str:
|
|
222
261
|
return "MSE"
|
|
223
262
|
|
|
224
|
-
|
|
225
|
-
|
|
263
|
+
@classmethod
|
|
264
|
+
def _get_loss_function_formula(cls, context) -> str:
|
|
265
|
+
if context.num_output_vars == 1:
|
|
226
266
|
return r"L(y, p) = (y - p)^2"
|
|
227
|
-
|
|
228
|
-
return r"L(\mathbf{y}, \mathbf{p}) = \sum_{i=1}^{k} (y_i - p_i)^2"
|
|
267
|
+
return r"L(\mathbf{y}, \mathbf{p}) = \sum_{i=1}^{k} (y_i - p_i)^2"
|
|
229
268
|
|
|
230
|
-
|
|
269
|
+
@classmethod
|
|
270
|
+
def _create_data_table(cls, context, loss_answers: List[ca.Answer]) -> ca.Element:
|
|
231
271
|
"""Create table with input features, true values, predictions, and loss fields."""
|
|
232
272
|
headers = ["x"]
|
|
233
273
|
|
|
234
|
-
if
|
|
274
|
+
if context.num_output_vars == 1:
|
|
235
275
|
headers.extend(["y", "p", "loss"])
|
|
236
276
|
else:
|
|
237
277
|
# Multiple outputs
|
|
238
|
-
for i in range(
|
|
278
|
+
for i in range(context.num_output_vars):
|
|
239
279
|
headers.append(f"y_{i}")
|
|
240
|
-
for i in range(
|
|
280
|
+
for i in range(context.num_output_vars):
|
|
241
281
|
headers.append(f"p_{i}")
|
|
242
282
|
headers.append("loss")
|
|
243
283
|
|
|
244
284
|
rows = []
|
|
245
|
-
for i, sample in enumerate(
|
|
285
|
+
for i, sample in enumerate(context.data):
|
|
246
286
|
row = {}
|
|
247
287
|
|
|
248
288
|
# Input features as vector
|
|
@@ -250,17 +290,17 @@ class LossQuestion_Linear(LossQuestion):
|
|
|
250
290
|
row["x"] = x_vector
|
|
251
291
|
|
|
252
292
|
# True values
|
|
253
|
-
if
|
|
293
|
+
if context.num_output_vars == 1:
|
|
254
294
|
row["y"] = f"{sample['true_values']:.2f}"
|
|
255
295
|
else:
|
|
256
|
-
for j in range(
|
|
296
|
+
for j in range(context.num_output_vars):
|
|
257
297
|
row[f"y_{j}"] = f"{sample['true_values'][j]:.2f}"
|
|
258
298
|
|
|
259
299
|
# Predictions
|
|
260
|
-
if
|
|
300
|
+
if context.num_output_vars == 1:
|
|
261
301
|
row["p"] = f"{sample['predictions']:.2f}"
|
|
262
302
|
else:
|
|
263
|
-
for j in range(
|
|
303
|
+
for j in range(context.num_output_vars):
|
|
264
304
|
row[f"p_{j}"] = f"{sample['predictions'][j]:.2f}"
|
|
265
305
|
|
|
266
306
|
# Loss answer field
|
|
@@ -268,19 +308,20 @@ class LossQuestion_Linear(LossQuestion):
|
|
|
268
308
|
|
|
269
309
|
rows.append(row)
|
|
270
310
|
|
|
271
|
-
return
|
|
311
|
+
return cls.create_answer_table(headers, rows, answer_columns=["loss"])
|
|
272
312
|
|
|
273
|
-
|
|
313
|
+
@classmethod
|
|
314
|
+
def _create_calculation_steps(cls, context) -> ca.Element:
|
|
274
315
|
"""Show step-by-step MSE calculations."""
|
|
275
316
|
steps = ca.Section()
|
|
276
317
|
|
|
277
|
-
for i, sample in enumerate(
|
|
318
|
+
for i, sample in enumerate(context.data):
|
|
278
319
|
steps.add_element(ca.Paragraph([f"Sample {i+1}:"]))
|
|
279
320
|
|
|
280
|
-
if
|
|
321
|
+
if context.num_output_vars == 1:
|
|
281
322
|
y = sample['true_values']
|
|
282
323
|
p = sample['predictions']
|
|
283
|
-
loss =
|
|
324
|
+
loss = context.individual_losses[i]
|
|
284
325
|
diff = y - p
|
|
285
326
|
|
|
286
327
|
# Format the subtraction nicely to avoid double negatives
|
|
@@ -293,10 +334,10 @@ class LossQuestion_Linear(LossQuestion):
|
|
|
293
334
|
# Multi-output calculation
|
|
294
335
|
y_vals = sample['true_values']
|
|
295
336
|
p_vals = sample['predictions']
|
|
296
|
-
loss =
|
|
337
|
+
loss = context.individual_losses[i]
|
|
297
338
|
|
|
298
339
|
terms = []
|
|
299
|
-
for
|
|
340
|
+
for y, p in zip(y_vals, p_vals):
|
|
300
341
|
# Format the subtraction nicely to avoid double negatives
|
|
301
342
|
if p >= 0:
|
|
302
343
|
terms.append(f"({y:.2f} - {p:.2f})^2")
|
|
@@ -308,21 +349,22 @@ class LossQuestion_Linear(LossQuestion):
|
|
|
308
349
|
|
|
309
350
|
return steps
|
|
310
351
|
|
|
311
|
-
|
|
352
|
+
@classmethod
|
|
353
|
+
def _create_completed_table(cls, context) -> ca.Element:
|
|
312
354
|
"""Create table with all values including calculated losses."""
|
|
313
355
|
headers = ["x_0", "x_1"]
|
|
314
356
|
|
|
315
|
-
if
|
|
357
|
+
if context.num_output_vars == 1:
|
|
316
358
|
headers.extend(["y", "p", "loss"])
|
|
317
359
|
else:
|
|
318
|
-
for i in range(
|
|
360
|
+
for i in range(context.num_output_vars):
|
|
319
361
|
headers.append(f"y_{i}")
|
|
320
|
-
for i in range(
|
|
362
|
+
for i in range(context.num_output_vars):
|
|
321
363
|
headers.append(f"p_{i}")
|
|
322
364
|
headers.append("loss")
|
|
323
365
|
|
|
324
366
|
rows = []
|
|
325
|
-
for i, sample in enumerate(
|
|
367
|
+
for i, sample in enumerate(context.data):
|
|
326
368
|
row = []
|
|
327
369
|
|
|
328
370
|
# Input features
|
|
@@ -330,27 +372,28 @@ class LossQuestion_Linear(LossQuestion):
|
|
|
330
372
|
row.append(f"{x:.2f}")
|
|
331
373
|
|
|
332
374
|
# True values
|
|
333
|
-
if
|
|
375
|
+
if context.num_output_vars == 1:
|
|
334
376
|
row.append(f"{sample['true_values']:.2f}")
|
|
335
377
|
else:
|
|
336
378
|
for y in sample['true_values']:
|
|
337
379
|
row.append(f"{y:.2f}")
|
|
338
380
|
|
|
339
381
|
# Predictions
|
|
340
|
-
if
|
|
382
|
+
if context.num_output_vars == 1:
|
|
341
383
|
row.append(f"{sample['predictions']:.2f}")
|
|
342
384
|
else:
|
|
343
385
|
for p in sample['predictions']:
|
|
344
386
|
row.append(f"{p:.2f}")
|
|
345
387
|
|
|
346
388
|
# Calculated loss
|
|
347
|
-
row.append(f"{
|
|
389
|
+
row.append(f"{context.individual_losses[i]:.4f}")
|
|
348
390
|
|
|
349
391
|
rows.append(row)
|
|
350
392
|
|
|
351
393
|
return ca.Table(headers=headers, data=rows)
|
|
352
394
|
|
|
353
|
-
|
|
395
|
+
@classmethod
|
|
396
|
+
def _create_overall_loss_explanation(cls, context) -> ca.Element:
|
|
354
397
|
"""Explain overall MSE calculation."""
|
|
355
398
|
explanation = ca.Section()
|
|
356
399
|
|
|
@@ -358,8 +401,8 @@ class LossQuestion_Linear(LossQuestion):
|
|
|
358
401
|
"The overall MSE is the average of individual losses:"
|
|
359
402
|
]))
|
|
360
403
|
|
|
361
|
-
losses_str = " + ".join([f"{loss:.4f}" for loss in
|
|
362
|
-
calculation = f"MSE = \\frac{{{losses_str}}}{{{
|
|
404
|
+
losses_str = " + ".join([f"{loss:.4f}" for loss in context.individual_losses])
|
|
405
|
+
calculation = f"MSE = \\frac{{{losses_str}}}{{{context.num_samples}}} = {context.overall_loss:.4f}"
|
|
363
406
|
|
|
364
407
|
explanation.add_element(ca.Equation(calculation, inline=False))
|
|
365
408
|
|
|
@@ -370,30 +413,35 @@ class LossQuestion_Linear(LossQuestion):
|
|
|
370
413
|
class LossQuestion_Logistic(LossQuestion):
|
|
371
414
|
"""Binary logistic regression with log-loss."""
|
|
372
415
|
|
|
373
|
-
|
|
416
|
+
@classmethod
|
|
417
|
+
def _generate_data(cls, context):
|
|
374
418
|
"""Generate binary classification data."""
|
|
375
|
-
|
|
419
|
+
context.data = []
|
|
376
420
|
|
|
377
|
-
for
|
|
421
|
+
for _ in range(context.num_samples):
|
|
378
422
|
sample = {}
|
|
379
423
|
|
|
380
424
|
# Generate input features (rounded to 2 decimal places)
|
|
381
|
-
sample['inputs'] = [
|
|
425
|
+
sample['inputs'] = [
|
|
426
|
+
round(context.rng.uniform(-100, 100), 2)
|
|
427
|
+
for _ in range(context.num_input_features)
|
|
428
|
+
]
|
|
382
429
|
|
|
383
430
|
# Generate binary true values (0 or 1)
|
|
384
|
-
sample['true_values'] =
|
|
431
|
+
sample['true_values'] = context.rng.choice([0, 1])
|
|
385
432
|
|
|
386
433
|
# Generate predicted probabilities (between 0 and 1, rounded to 3 decimal places)
|
|
387
|
-
sample['predictions'] = round(
|
|
434
|
+
sample['predictions'] = round(context.rng.uniform(0.1, 0.9), 3) # Avoid extreme values
|
|
388
435
|
|
|
389
|
-
|
|
436
|
+
context.data.append(sample)
|
|
390
437
|
|
|
391
|
-
|
|
438
|
+
@classmethod
|
|
439
|
+
def _calculate_losses(cls, context):
|
|
392
440
|
"""Calculate log-loss for each sample and overall."""
|
|
393
|
-
|
|
441
|
+
context.individual_losses = []
|
|
394
442
|
total_loss = 0.0
|
|
395
443
|
|
|
396
|
-
for sample in
|
|
444
|
+
for sample in context.data:
|
|
397
445
|
y = sample['true_values']
|
|
398
446
|
p = sample['predictions']
|
|
399
447
|
|
|
@@ -403,27 +451,31 @@ class LossQuestion_Logistic(LossQuestion):
|
|
|
403
451
|
else:
|
|
404
452
|
loss = -math.log(1 - p)
|
|
405
453
|
|
|
406
|
-
|
|
454
|
+
context.individual_losses.append(loss)
|
|
407
455
|
total_loss += loss
|
|
408
456
|
|
|
409
457
|
# Overall log-loss is average of individual losses
|
|
410
|
-
|
|
458
|
+
context.overall_loss = total_loss / context.num_samples
|
|
411
459
|
|
|
412
|
-
|
|
460
|
+
@classmethod
|
|
461
|
+
def _get_loss_function_name(cls, context) -> str:
|
|
413
462
|
return "Log-Loss (Binary Cross-Entropy)"
|
|
414
463
|
|
|
415
|
-
|
|
464
|
+
@classmethod
|
|
465
|
+
def _get_loss_function_short_name(cls, context) -> str:
|
|
416
466
|
return "log-loss"
|
|
417
467
|
|
|
418
|
-
|
|
468
|
+
@classmethod
|
|
469
|
+
def _get_loss_function_formula(cls, context) -> str:
|
|
419
470
|
return r"L(y, p) = -[y \ln(p) + (1-y) \ln(1-p)]"
|
|
420
471
|
|
|
421
|
-
|
|
472
|
+
@classmethod
|
|
473
|
+
def _create_data_table(cls, context, loss_answers: List[ca.Answer]) -> ca.Element:
|
|
422
474
|
"""Create table with features, true labels, predicted probabilities, and loss fields."""
|
|
423
475
|
headers = ["x", "y", "p", "loss"]
|
|
424
476
|
|
|
425
477
|
rows = []
|
|
426
|
-
for i, sample in enumerate(
|
|
478
|
+
for i, sample in enumerate(context.data):
|
|
427
479
|
row = {}
|
|
428
480
|
|
|
429
481
|
# Input features as vector
|
|
@@ -441,16 +493,17 @@ class LossQuestion_Logistic(LossQuestion):
|
|
|
441
493
|
|
|
442
494
|
rows.append(row)
|
|
443
495
|
|
|
444
|
-
return
|
|
496
|
+
return cls.create_answer_table(headers, rows, answer_columns=["loss"])
|
|
445
497
|
|
|
446
|
-
|
|
498
|
+
@classmethod
|
|
499
|
+
def _create_calculation_steps(cls, context) -> ca.Element:
|
|
447
500
|
"""Show step-by-step log-loss calculations."""
|
|
448
501
|
steps = ca.Section()
|
|
449
502
|
|
|
450
|
-
for i, sample in enumerate(
|
|
503
|
+
for i, sample in enumerate(context.data):
|
|
451
504
|
y = sample['true_values']
|
|
452
505
|
p = sample['predictions']
|
|
453
|
-
loss =
|
|
506
|
+
loss = context.individual_losses[i]
|
|
454
507
|
|
|
455
508
|
steps.add_element(ca.Paragraph([f"Sample {i+1}:"]))
|
|
456
509
|
|
|
@@ -463,12 +516,13 @@ class LossQuestion_Logistic(LossQuestion):
|
|
|
463
516
|
|
|
464
517
|
return steps
|
|
465
518
|
|
|
466
|
-
|
|
519
|
+
@classmethod
|
|
520
|
+
def _create_completed_table(cls, context) -> ca.Element:
|
|
467
521
|
"""Create table with all values including calculated losses."""
|
|
468
522
|
headers = ["x_0", "x_1", "y", "p", "loss"]
|
|
469
523
|
|
|
470
524
|
rows = []
|
|
471
|
-
for i, sample in enumerate(
|
|
525
|
+
for i, sample in enumerate(context.data):
|
|
472
526
|
row = []
|
|
473
527
|
|
|
474
528
|
# Input features
|
|
@@ -482,13 +536,14 @@ class LossQuestion_Logistic(LossQuestion):
|
|
|
482
536
|
row.append(f"{sample['predictions']:.3f}")
|
|
483
537
|
|
|
484
538
|
# Calculated loss
|
|
485
|
-
row.append(f"{
|
|
539
|
+
row.append(f"{context.individual_losses[i]:.4f}")
|
|
486
540
|
|
|
487
541
|
rows.append(row)
|
|
488
542
|
|
|
489
543
|
return ca.Table(headers=headers, data=rows)
|
|
490
544
|
|
|
491
|
-
|
|
545
|
+
@classmethod
|
|
546
|
+
def _create_overall_loss_explanation(cls, context) -> ca.Element:
|
|
492
547
|
"""Explain overall log-loss calculation."""
|
|
493
548
|
explanation = ca.Section()
|
|
494
549
|
|
|
@@ -496,8 +551,8 @@ class LossQuestion_Logistic(LossQuestion):
|
|
|
496
551
|
"The overall log-loss is the average of individual losses:"
|
|
497
552
|
]))
|
|
498
553
|
|
|
499
|
-
losses_str = " + ".join([f"{loss:.4f}" for loss in
|
|
500
|
-
calculation = f"\\text{{Log-Loss}} = \\frac{{{losses_str}}}{{{
|
|
554
|
+
losses_str = " + ".join([f"{loss:.4f}" for loss in context.individual_losses])
|
|
555
|
+
calculation = f"\\text{{Log-Loss}} = \\frac{{{losses_str}}}{{{context.num_samples}}} = {context.overall_loss:.4f}"
|
|
501
556
|
|
|
502
557
|
explanation.add_element(ca.Equation(calculation, inline=False))
|
|
503
558
|
|
|
@@ -508,71 +563,89 @@ class LossQuestion_Logistic(LossQuestion):
|
|
|
508
563
|
class LossQuestion_MulticlassLogistic(LossQuestion):
|
|
509
564
|
"""Multi-class logistic regression with cross-entropy loss."""
|
|
510
565
|
|
|
566
|
+
DEFAULT_NUM_CLASSES = 3
|
|
567
|
+
|
|
511
568
|
def __init__(self, *args, **kwargs):
|
|
512
|
-
self.num_classes = kwargs.get("num_classes",
|
|
569
|
+
self.num_classes = kwargs.get("num_classes", self.DEFAULT_NUM_CLASSES)
|
|
513
570
|
self.num_classes = max(3, min(5, self.num_classes)) # Constrain to 3-5 classes
|
|
514
571
|
super().__init__(*args, **kwargs)
|
|
515
572
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
self.num_classes = max(3, min(5, kwargs.get("num_classes", self.num_classes)))
|
|
573
|
+
@classmethod
|
|
574
|
+
def _build_context(cls, *, rng_seed=None, **kwargs):
|
|
519
575
|
return super()._build_context(rng_seed=rng_seed, **kwargs)
|
|
520
576
|
|
|
521
|
-
|
|
577
|
+
@classmethod
|
|
578
|
+
def _populate_context(cls, context, **kwargs):
|
|
579
|
+
context.num_classes = max(
|
|
580
|
+
3,
|
|
581
|
+
min(5, kwargs.get("num_classes", cls.DEFAULT_NUM_CLASSES))
|
|
582
|
+
)
|
|
583
|
+
return context
|
|
584
|
+
|
|
585
|
+
@classmethod
|
|
586
|
+
def _generate_data(cls, context):
|
|
522
587
|
"""Generate multi-class classification data."""
|
|
523
|
-
|
|
588
|
+
context.data = []
|
|
524
589
|
|
|
525
|
-
for
|
|
590
|
+
for _ in range(context.num_samples):
|
|
526
591
|
sample = {}
|
|
527
592
|
|
|
528
593
|
# Generate input features (rounded to 2 decimal places)
|
|
529
|
-
sample['inputs'] = [
|
|
594
|
+
sample['inputs'] = [
|
|
595
|
+
round(context.rng.uniform(-100, 100), 2)
|
|
596
|
+
for _ in range(context.num_input_features)
|
|
597
|
+
]
|
|
530
598
|
|
|
531
599
|
# Generate true class (one-hot encoded) - ensure exactly one class is 1
|
|
532
|
-
true_class_idx =
|
|
533
|
-
sample['true_values'] = [0] *
|
|
534
|
-
sample['true_values'][true_class_idx] = 1
|
|
600
|
+
true_class_idx = context.rng.randint(0, context.num_classes - 1)
|
|
601
|
+
sample['true_values'] = [0] * context.num_classes # Start with all zeros
|
|
602
|
+
sample['true_values'][true_class_idx] = 1 # Set exactly one to 1
|
|
535
603
|
|
|
536
604
|
# Generate predicted probabilities (softmax-like, sum to 1, rounded to 3 decimal places)
|
|
537
|
-
raw_probs = [
|
|
605
|
+
raw_probs = [context.rng.uniform(0.1, 2.0) for _ in range(context.num_classes)]
|
|
538
606
|
prob_sum = sum(raw_probs)
|
|
539
607
|
sample['predictions'] = [round(p / prob_sum, 3) for p in raw_probs]
|
|
540
608
|
|
|
541
|
-
|
|
609
|
+
context.data.append(sample)
|
|
542
610
|
|
|
543
|
-
|
|
611
|
+
@classmethod
|
|
612
|
+
def _calculate_losses(cls, context):
|
|
544
613
|
"""Calculate cross-entropy loss for each sample and overall."""
|
|
545
|
-
|
|
614
|
+
context.individual_losses = []
|
|
546
615
|
total_loss = 0.0
|
|
547
616
|
|
|
548
|
-
for sample in
|
|
617
|
+
for sample in context.data:
|
|
549
618
|
y_vec = sample['true_values']
|
|
550
619
|
p_vec = sample['predictions']
|
|
551
620
|
|
|
552
621
|
# Cross-entropy: -sum(y_i * log(p_i))
|
|
553
622
|
loss = -sum(y * math.log(max(p, 1e-15)) for y, p in zip(y_vec, p_vec) if y > 0)
|
|
554
623
|
|
|
555
|
-
|
|
624
|
+
context.individual_losses.append(loss)
|
|
556
625
|
total_loss += loss
|
|
557
626
|
|
|
558
627
|
# Overall cross-entropy is average of individual losses
|
|
559
|
-
|
|
628
|
+
context.overall_loss = total_loss / context.num_samples
|
|
560
629
|
|
|
561
|
-
|
|
630
|
+
@classmethod
|
|
631
|
+
def _get_loss_function_name(cls, context) -> str:
|
|
562
632
|
return "Cross-Entropy Loss"
|
|
563
633
|
|
|
564
|
-
|
|
634
|
+
@classmethod
|
|
635
|
+
def _get_loss_function_short_name(cls, context) -> str:
|
|
565
636
|
return "cross-entropy loss"
|
|
566
637
|
|
|
567
|
-
|
|
638
|
+
@classmethod
|
|
639
|
+
def _get_loss_function_formula(cls, context) -> str:
|
|
568
640
|
return r"L(\mathbf{y}, \mathbf{p}) = -\sum_{i=1}^{K} y_i \ln(p_i)"
|
|
569
641
|
|
|
570
|
-
|
|
642
|
+
@classmethod
|
|
643
|
+
def _create_data_table(cls, context, loss_answers: List[ca.Answer]) -> ca.Element:
|
|
571
644
|
"""Create table with features, true class vectors, predicted probabilities, and loss fields."""
|
|
572
645
|
headers = ["x", "y", "p", "loss"]
|
|
573
646
|
|
|
574
647
|
rows = []
|
|
575
|
-
for i, sample in enumerate(
|
|
648
|
+
for i, sample in enumerate(context.data):
|
|
576
649
|
row = {}
|
|
577
650
|
|
|
578
651
|
# Input features as vector
|
|
@@ -592,16 +665,17 @@ class LossQuestion_MulticlassLogistic(LossQuestion):
|
|
|
592
665
|
|
|
593
666
|
rows.append(row)
|
|
594
667
|
|
|
595
|
-
return
|
|
668
|
+
return cls.create_answer_table(headers, rows, answer_columns=["loss"])
|
|
596
669
|
|
|
597
|
-
|
|
670
|
+
@classmethod
|
|
671
|
+
def _create_calculation_steps(cls, context) -> ca.Element:
|
|
598
672
|
"""Show step-by-step cross-entropy calculations."""
|
|
599
673
|
steps = ca.Section()
|
|
600
674
|
|
|
601
|
-
for i, sample in enumerate(
|
|
675
|
+
for i, sample in enumerate(context.data):
|
|
602
676
|
y_vec = sample['true_values']
|
|
603
677
|
p_vec = sample['predictions']
|
|
604
|
-
loss =
|
|
678
|
+
loss = context.individual_losses[i]
|
|
605
679
|
|
|
606
680
|
steps.add_element(ca.Paragraph([f"Sample {i+1}:"]))
|
|
607
681
|
|
|
@@ -618,11 +692,8 @@ class LossQuestion_MulticlassLogistic(LossQuestion):
|
|
|
618
692
|
|
|
619
693
|
# Show the vector multiplication more explicitly
|
|
620
694
|
terms = []
|
|
621
|
-
for
|
|
622
|
-
|
|
623
|
-
terms.append(f"{y} \\cdot \\ln({p:.3f})")
|
|
624
|
-
else:
|
|
625
|
-
terms.append(f"{y} \\cdot \\ln({p:.3f})")
|
|
695
|
+
for y, p in zip(y_vec, p_vec):
|
|
696
|
+
terms.append(f"{y} \\cdot \\ln({p:.3f})")
|
|
626
697
|
|
|
627
698
|
calculation = f"L = -\\mathbf{{y}} \\cdot \\ln(\\mathbf{{p}}) = -({' + '.join(terms)}) = -{y_vec[true_class_idx]} \\cdot \\ln({p_true:.3f}) = {loss:.4f}"
|
|
628
699
|
except ValueError:
|
|
@@ -633,12 +704,13 @@ class LossQuestion_MulticlassLogistic(LossQuestion):
|
|
|
633
704
|
|
|
634
705
|
return steps
|
|
635
706
|
|
|
636
|
-
|
|
707
|
+
@classmethod
|
|
708
|
+
def _create_completed_table(cls, context) -> ca.Element:
|
|
637
709
|
"""Create table with all values including calculated losses."""
|
|
638
710
|
headers = ["x_0", "x_1", "y", "p", "loss"]
|
|
639
711
|
|
|
640
712
|
rows = []
|
|
641
|
-
for i, sample in enumerate(
|
|
713
|
+
for i, sample in enumerate(context.data):
|
|
642
714
|
row = []
|
|
643
715
|
|
|
644
716
|
# Input features
|
|
@@ -654,13 +726,14 @@ class LossQuestion_MulticlassLogistic(LossQuestion):
|
|
|
654
726
|
row.append(p_vector)
|
|
655
727
|
|
|
656
728
|
# Calculated loss
|
|
657
|
-
row.append(f"{
|
|
729
|
+
row.append(f"{context.individual_losses[i]:.4f}")
|
|
658
730
|
|
|
659
731
|
rows.append(row)
|
|
660
732
|
|
|
661
733
|
return ca.Table(headers=headers, data=rows)
|
|
662
734
|
|
|
663
|
-
|
|
735
|
+
@classmethod
|
|
736
|
+
def _create_overall_loss_explanation(cls, context) -> ca.Element:
|
|
664
737
|
"""Explain overall cross-entropy loss calculation."""
|
|
665
738
|
explanation = ca.Section()
|
|
666
739
|
|
|
@@ -668,8 +741,8 @@ class LossQuestion_MulticlassLogistic(LossQuestion):
|
|
|
668
741
|
"The overall cross-entropy loss is the average of individual losses:"
|
|
669
742
|
]))
|
|
670
743
|
|
|
671
|
-
losses_str = " + ".join([f"{loss:.4f}" for loss in
|
|
672
|
-
calculation = f"\\text{{Cross-Entropy}} = \\frac{{{losses_str}}}{{{
|
|
744
|
+
losses_str = " + ".join([f"{loss:.4f}" for loss in context.individual_losses])
|
|
745
|
+
calculation = f"\\text{{Cross-Entropy}} = \\frac{{{losses_str}}}{{{context.num_samples}}} = {context.overall_loss:.4f}"
|
|
673
746
|
|
|
674
747
|
explanation.add_element(ca.Equation(calculation, inline=False))
|
|
675
748
|
|