QuizGenerator 0.8.0__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.
@@ -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", 5)
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", 2)
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", False) # Whether to show inputs as vectors
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
- def _build_context(self, *, rng_seed=None, **kwargs):
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
- if "num_samples" in kwargs:
42
- self.num_samples = max(3, min(10, kwargs.get("num_samples", self.num_samples)))
43
- if "num_input_features" in kwargs:
44
- self.num_input_features = max(1, min(5, kwargs.get("num_input_features", self.num_input_features)))
45
- if "vector_inputs" in kwargs:
46
- self.vector_inputs = kwargs.get("vector_inputs", self.vector_inputs)
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(self):
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(self):
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(self) -> str:
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(self) -> str:
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(self) -> str:
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
- def _build_loss_answers(self) -> Tuple[List[ca.Answer], ca.Answer]:
92
+ @classmethod
93
+ def _build_loss_answers(cls, context) -> Tuple[List[ca.Answer], ca.Answer]:
83
94
  answers = [
84
- ca.AnswerTypes.Float(self.individual_losses[i], label=f"Sample {i + 1} loss")
85
- for i in range(self.num_samples)
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(self.overall_loss, label="Overall loss")
98
+ overall = ca.AnswerTypes.Float(context.overall_loss, label="Overall loss")
88
99
  return answers, overall
89
100
 
90
- def _build_body(self, context) -> Tuple[ca.Element, List[ca.Answer]]:
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 {self._get_loss_function_short_name()} for each sample "
98
- f"and the overall {self._get_loss_function_short_name()}."
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 = self._build_loss_answers()
103
- body.add_element(self._create_data_table(loss_answers))
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 {self._get_loss_function_short_name()}: "
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(self, loss_answers: List[ca.Answer]) -> ca.Element:
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
- def _build_explanation(self, context) -> Tuple[ca.Element, List[ca.Answer]]:
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 {self._get_loss_function_name()}, we apply the formula to each sample:"
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(self._get_loss_function_formula(), inline=False))
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(self._create_calculation_steps())
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(self._create_completed_table())
149
+ explanation.add_element(cls._create_completed_table(context))
136
150
 
137
151
  # Overall loss calculation
138
- explanation.add_element(self._create_overall_loss_explanation())
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(self) -> ca.Element:
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(self) -> ca.Element:
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(self) -> ca.Element:
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", 1)
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
- def _build_context(self, *, rng_seed=None, **kwargs):
168
- if "num_output_vars" in kwargs:
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
- def _generate_data(self):
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
- self.data = []
201
+ context.data = []
175
202
 
176
- for i in range(self.num_samples):
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'] = [round(self.rng.uniform(-100, 100), 2) for _ in range(self.num_input_features)]
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 self.num_output_vars == 1:
184
- sample['true_values'] = round(self.rng.uniform(-100, 100), 2)
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'] = [round(self.rng.uniform(-100, 100), 2) for _ in range(self.num_output_vars)]
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 self.num_output_vars == 1:
190
- sample['predictions'] = round(self.rng.uniform(-100, 100), 2)
222
+ if context.num_output_vars == 1:
223
+ sample['predictions'] = round(context.rng.uniform(-100, 100), 2)
191
224
  else:
192
- sample['predictions'] = [round(self.rng.uniform(-100, 100), 2) for _ in range(self.num_output_vars)]
225
+ sample['predictions'] = [
226
+ round(context.rng.uniform(-100, 100), 2)
227
+ for _ in range(context.num_output_vars)
228
+ ]
193
229
 
194
- self.data.append(sample)
230
+ context.data.append(sample)
195
231
 
196
- def _calculate_losses(self):
232
+ @classmethod
233
+ def _calculate_losses(cls, context):
197
234
  """Calculate MSE for each sample and overall."""
198
- self.individual_losses = []
235
+ context.individual_losses = []
199
236
  total_loss = 0.0
200
237
 
201
- for sample in self.data:
202
- if self.num_output_vars == 1:
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
- self.individual_losses.append(loss)
249
+ context.individual_losses.append(loss)
213
250
  total_loss += loss
214
251
 
215
252
  # Overall MSE is average of individual losses
216
- self.overall_loss = total_loss / self.num_samples
253
+ context.overall_loss = total_loss / context.num_samples
217
254
 
218
- def _get_loss_function_name(self) -> str:
255
+ @classmethod
256
+ def _get_loss_function_name(cls, context) -> str:
219
257
  return "Mean Squared Error (MSE)"
220
258
 
221
- def _get_loss_function_short_name(self) -> str:
259
+ @classmethod
260
+ def _get_loss_function_short_name(cls, context) -> str:
222
261
  return "MSE"
223
262
 
224
- def _get_loss_function_formula(self) -> str:
225
- if self.num_output_vars == 1:
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
- else:
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
- def _create_data_table(self, loss_answers: List[ca.Answer]) -> ca.Element:
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 self.num_output_vars == 1:
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(self.num_output_vars):
278
+ for i in range(context.num_output_vars):
239
279
  headers.append(f"y_{i}")
240
- for i in range(self.num_output_vars):
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(self.data):
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 self.num_output_vars == 1:
293
+ if context.num_output_vars == 1:
254
294
  row["y"] = f"{sample['true_values']:.2f}"
255
295
  else:
256
- for j in range(self.num_output_vars):
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 self.num_output_vars == 1:
300
+ if context.num_output_vars == 1:
261
301
  row["p"] = f"{sample['predictions']:.2f}"
262
302
  else:
263
- for j in range(self.num_output_vars):
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 self.create_answer_table(headers, rows, answer_columns=["loss"])
311
+ return cls.create_answer_table(headers, rows, answer_columns=["loss"])
272
312
 
273
- def _create_calculation_steps(self) -> ca.Element:
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(self.data):
318
+ for i, sample in enumerate(context.data):
278
319
  steps.add_element(ca.Paragraph([f"Sample {i+1}:"]))
279
320
 
280
- if self.num_output_vars == 1:
321
+ if context.num_output_vars == 1:
281
322
  y = sample['true_values']
282
323
  p = sample['predictions']
283
- loss = self.individual_losses[i]
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 = self.individual_losses[i]
337
+ loss = context.individual_losses[i]
297
338
 
298
339
  terms = []
299
- for j, (y, p) in enumerate(zip(y_vals, p_vals)):
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
- def _create_completed_table(self) -> ca.Element:
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 self.num_output_vars == 1:
357
+ if context.num_output_vars == 1:
316
358
  headers.extend(["y", "p", "loss"])
317
359
  else:
318
- for i in range(self.num_output_vars):
360
+ for i in range(context.num_output_vars):
319
361
  headers.append(f"y_{i}")
320
- for i in range(self.num_output_vars):
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(self.data):
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 self.num_output_vars == 1:
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 self.num_output_vars == 1:
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"{self.individual_losses[i]:.4f}")
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
- def _create_overall_loss_explanation(self) -> ca.Element:
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 self.individual_losses])
362
- calculation = f"MSE = \\frac{{{losses_str}}}{{{self.num_samples}}} = {self.overall_loss:.4f}"
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
- def _generate_data(self):
416
+ @classmethod
417
+ def _generate_data(cls, context):
374
418
  """Generate binary classification data."""
375
- self.data = []
419
+ context.data = []
376
420
 
377
- for i in range(self.num_samples):
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'] = [round(self.rng.uniform(-100, 100), 2) for _ in range(self.num_input_features)]
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'] = self.rng.choice([0, 1])
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(self.rng.uniform(0.1, 0.9), 3) # Avoid extreme values
434
+ sample['predictions'] = round(context.rng.uniform(0.1, 0.9), 3) # Avoid extreme values
388
435
 
389
- self.data.append(sample)
436
+ context.data.append(sample)
390
437
 
391
- def _calculate_losses(self):
438
+ @classmethod
439
+ def _calculate_losses(cls, context):
392
440
  """Calculate log-loss for each sample and overall."""
393
- self.individual_losses = []
441
+ context.individual_losses = []
394
442
  total_loss = 0.0
395
443
 
396
- for sample in self.data:
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
- self.individual_losses.append(loss)
454
+ context.individual_losses.append(loss)
407
455
  total_loss += loss
408
456
 
409
457
  # Overall log-loss is average of individual losses
410
- self.overall_loss = total_loss / self.num_samples
458
+ context.overall_loss = total_loss / context.num_samples
411
459
 
412
- def _get_loss_function_name(self) -> str:
460
+ @classmethod
461
+ def _get_loss_function_name(cls, context) -> str:
413
462
  return "Log-Loss (Binary Cross-Entropy)"
414
463
 
415
- def _get_loss_function_short_name(self) -> str:
464
+ @classmethod
465
+ def _get_loss_function_short_name(cls, context) -> str:
416
466
  return "log-loss"
417
467
 
418
- def _get_loss_function_formula(self) -> str:
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
- def _create_data_table(self, loss_answers: List[ca.Answer]) -> ca.Element:
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(self.data):
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 self.create_answer_table(headers, rows, answer_columns=["loss"])
496
+ return cls.create_answer_table(headers, rows, answer_columns=["loss"])
445
497
 
446
- def _create_calculation_steps(self) -> ca.Element:
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(self.data):
503
+ for i, sample in enumerate(context.data):
451
504
  y = sample['true_values']
452
505
  p = sample['predictions']
453
- loss = self.individual_losses[i]
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
- def _create_completed_table(self) -> ca.Element:
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(self.data):
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"{self.individual_losses[i]:.4f}")
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
- def _create_overall_loss_explanation(self) -> ca.Element:
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 self.individual_losses])
500
- calculation = f"\\text{{Log-Loss}} = \\frac{{{losses_str}}}{{{self.num_samples}}} = {self.overall_loss:.4f}"
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", 3)
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
- def _build_context(self, *, rng_seed=None, **kwargs):
517
- if "num_classes" in kwargs:
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
- def _generate_data(self):
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
- self.data = []
588
+ context.data = []
524
589
 
525
- for i in range(self.num_samples):
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'] = [round(self.rng.uniform(-100, 100), 2) for _ in range(self.num_input_features)]
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 = self.rng.randint(0, self.num_classes - 1)
533
- sample['true_values'] = [0] * self.num_classes # Start with all zeros
534
- sample['true_values'][true_class_idx] = 1 # Set exactly one to 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 = [self.rng.uniform(0.1, 2.0) for _ in range(self.num_classes)]
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
- self.data.append(sample)
609
+ context.data.append(sample)
542
610
 
543
- def _calculate_losses(self):
611
+ @classmethod
612
+ def _calculate_losses(cls, context):
544
613
  """Calculate cross-entropy loss for each sample and overall."""
545
- self.individual_losses = []
614
+ context.individual_losses = []
546
615
  total_loss = 0.0
547
616
 
548
- for sample in self.data:
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
- self.individual_losses.append(loss)
624
+ context.individual_losses.append(loss)
556
625
  total_loss += loss
557
626
 
558
627
  # Overall cross-entropy is average of individual losses
559
- self.overall_loss = total_loss / self.num_samples
628
+ context.overall_loss = total_loss / context.num_samples
560
629
 
561
- def _get_loss_function_name(self) -> str:
630
+ @classmethod
631
+ def _get_loss_function_name(cls, context) -> str:
562
632
  return "Cross-Entropy Loss"
563
633
 
564
- def _get_loss_function_short_name(self) -> str:
634
+ @classmethod
635
+ def _get_loss_function_short_name(cls, context) -> str:
565
636
  return "cross-entropy loss"
566
637
 
567
- def _get_loss_function_formula(self) -> str:
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
- def _create_data_table(self, loss_answers: List[ca.Answer]) -> ca.Element:
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(self.data):
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 self.create_answer_table(headers, rows, answer_columns=["loss"])
668
+ return cls.create_answer_table(headers, rows, answer_columns=["loss"])
596
669
 
597
- def _create_calculation_steps(self) -> ca.Element:
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(self.data):
675
+ for i, sample in enumerate(context.data):
602
676
  y_vec = sample['true_values']
603
677
  p_vec = sample['predictions']
604
- loss = self.individual_losses[i]
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 j, (y, p) in enumerate(zip(y_vec, p_vec)):
622
- if y == 1:
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
- def _create_completed_table(self) -> ca.Element:
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(self.data):
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"{self.individual_losses[i]:.4f}")
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
- def _create_overall_loss_explanation(self) -> ca.Element:
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 self.individual_losses])
672
- calculation = f"\\text{{Cross-Entropy}} = \\frac{{{losses_str}}}{{{self.num_samples}}} = {self.overall_loss:.4f}"
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