QuizGenerator 0.4.2__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/README.md +5 -0
- QuizGenerator/__init__.py +27 -0
- QuizGenerator/__main__.py +7 -0
- QuizGenerator/canvas/__init__.py +13 -0
- QuizGenerator/canvas/canvas_interface.py +627 -0
- QuizGenerator/canvas/classes.py +235 -0
- QuizGenerator/constants.py +149 -0
- QuizGenerator/contentast.py +1955 -0
- QuizGenerator/generate.py +253 -0
- QuizGenerator/logging.yaml +55 -0
- QuizGenerator/misc.py +579 -0
- QuizGenerator/mixins.py +548 -0
- QuizGenerator/performance.py +202 -0
- QuizGenerator/premade_questions/__init__.py +0 -0
- QuizGenerator/premade_questions/basic.py +103 -0
- QuizGenerator/premade_questions/cst334/__init__.py +1 -0
- QuizGenerator/premade_questions/cst334/languages.py +391 -0
- QuizGenerator/premade_questions/cst334/math_questions.py +297 -0
- QuizGenerator/premade_questions/cst334/memory_questions.py +1400 -0
- QuizGenerator/premade_questions/cst334/ostep13_vsfs.py +572 -0
- QuizGenerator/premade_questions/cst334/persistence_questions.py +451 -0
- QuizGenerator/premade_questions/cst334/process.py +648 -0
- QuizGenerator/premade_questions/cst463/__init__.py +0 -0
- QuizGenerator/premade_questions/cst463/gradient_descent/__init__.py +3 -0
- QuizGenerator/premade_questions/cst463/gradient_descent/gradient_calculation.py +369 -0
- QuizGenerator/premade_questions/cst463/gradient_descent/gradient_descent_questions.py +305 -0
- QuizGenerator/premade_questions/cst463/gradient_descent/loss_calculations.py +650 -0
- QuizGenerator/premade_questions/cst463/gradient_descent/misc.py +73 -0
- QuizGenerator/premade_questions/cst463/math_and_data/__init__.py +2 -0
- QuizGenerator/premade_questions/cst463/math_and_data/matrix_questions.py +631 -0
- QuizGenerator/premade_questions/cst463/math_and_data/vector_questions.py +534 -0
- QuizGenerator/premade_questions/cst463/models/__init__.py +0 -0
- QuizGenerator/premade_questions/cst463/models/attention.py +192 -0
- QuizGenerator/premade_questions/cst463/models/cnns.py +186 -0
- QuizGenerator/premade_questions/cst463/models/matrices.py +24 -0
- QuizGenerator/premade_questions/cst463/models/rnns.py +202 -0
- QuizGenerator/premade_questions/cst463/models/text.py +203 -0
- QuizGenerator/premade_questions/cst463/models/weight_counting.py +227 -0
- QuizGenerator/premade_questions/cst463/neural-network-basics/__init__.py +6 -0
- QuizGenerator/premade_questions/cst463/neural-network-basics/neural_network_questions.py +1314 -0
- QuizGenerator/premade_questions/cst463/tensorflow-intro/__init__.py +6 -0
- QuizGenerator/premade_questions/cst463/tensorflow-intro/tensorflow_questions.py +936 -0
- QuizGenerator/qrcode_generator.py +293 -0
- QuizGenerator/question.py +715 -0
- QuizGenerator/quiz.py +467 -0
- QuizGenerator/regenerate.py +472 -0
- QuizGenerator/typst_utils.py +113 -0
- quizgenerator-0.4.2.dist-info/METADATA +265 -0
- quizgenerator-0.4.2.dist-info/RECORD +52 -0
- quizgenerator-0.4.2.dist-info/WHEEL +4 -0
- quizgenerator-0.4.2.dist-info/entry_points.txt +3 -0
- quizgenerator-0.4.2.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import abc
|
|
4
|
+
import logging
|
|
5
|
+
from typing import List
|
|
6
|
+
import sympy as sp
|
|
7
|
+
|
|
8
|
+
from QuizGenerator.contentast import ContentAST
|
|
9
|
+
from QuizGenerator.question import Question, Answer, QuestionRegistry
|
|
10
|
+
from .misc import generate_function, format_vector
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DerivativeQuestion(Question, abc.ABC):
|
|
16
|
+
"""Base class for derivative calculation questions."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, *args, **kwargs):
|
|
19
|
+
kwargs["topic"] = kwargs.get("topic", Question.Topic.ML_OPTIMIZATION)
|
|
20
|
+
super().__init__(*args, **kwargs)
|
|
21
|
+
self.num_variables = kwargs.get("num_variables", 2)
|
|
22
|
+
self.max_degree = kwargs.get("max_degree", 2)
|
|
23
|
+
|
|
24
|
+
def _generate_evaluation_point(self) -> List[float]:
|
|
25
|
+
"""Generate a random point for gradient evaluation."""
|
|
26
|
+
return [self.rng.randint(-3, 3) for _ in range(self.num_variables)]
|
|
27
|
+
|
|
28
|
+
def _format_partial_derivative(self, var_index: int) -> str:
|
|
29
|
+
"""Format partial derivative symbol for display."""
|
|
30
|
+
if self.num_variables == 1:
|
|
31
|
+
return "\\frac{df}{dx_0}"
|
|
32
|
+
else:
|
|
33
|
+
return f"\\frac{{\\partial f}}{{\\partial x_{var_index}}}"
|
|
34
|
+
|
|
35
|
+
def _create_derivative_answers(self, evaluation_point: List[float]) -> None:
|
|
36
|
+
"""Create answer fields for each partial derivative at the evaluation point."""
|
|
37
|
+
self.answers = {}
|
|
38
|
+
|
|
39
|
+
# Evaluate gradient at the specified point
|
|
40
|
+
subs_map = dict(zip(self.variables, evaluation_point))
|
|
41
|
+
|
|
42
|
+
# Create answer for each partial derivative
|
|
43
|
+
for i in range(self.num_variables):
|
|
44
|
+
answer_key = f"partial_derivative_{i}"
|
|
45
|
+
# Evaluate the partial derivative and convert to float
|
|
46
|
+
partial_value = self.gradient_function[i].subs(subs_map)
|
|
47
|
+
try:
|
|
48
|
+
gradient_value = float(partial_value)
|
|
49
|
+
except (TypeError, ValueError):
|
|
50
|
+
# If we get a complex number or other conversion error,
|
|
51
|
+
# this likely means log hit a negative value - regenerate
|
|
52
|
+
raise ValueError("Complex number encountered - need to regenerate")
|
|
53
|
+
|
|
54
|
+
# Use auto_float for Canvas compatibility with integers and decimals
|
|
55
|
+
self.answers[answer_key] = Answer.auto_float(answer_key, gradient_value)
|
|
56
|
+
|
|
57
|
+
def _create_gradient_vector_answer(self) -> None:
|
|
58
|
+
"""Create a single gradient vector answer for PDF format."""
|
|
59
|
+
# Format gradient as vector notation
|
|
60
|
+
subs_map = dict(zip(self.variables, self.evaluation_point))
|
|
61
|
+
gradient_values = []
|
|
62
|
+
|
|
63
|
+
for i in range(self.num_variables):
|
|
64
|
+
partial_value = self.gradient_function[i].subs(subs_map)
|
|
65
|
+
try:
|
|
66
|
+
gradient_value = float(partial_value)
|
|
67
|
+
except TypeError:
|
|
68
|
+
gradient_value = float(partial_value.evalf())
|
|
69
|
+
gradient_values.append(gradient_value)
|
|
70
|
+
|
|
71
|
+
# Format as vector for display using consistent formatting
|
|
72
|
+
vector_str = format_vector(gradient_values)
|
|
73
|
+
self.answers["gradient_vector"] = Answer.string("gradient_vector", vector_str, pdf_only=True)
|
|
74
|
+
|
|
75
|
+
def get_body(self, **kwargs) -> ContentAST.Section:
|
|
76
|
+
body = ContentAST.Section()
|
|
77
|
+
|
|
78
|
+
# Display the function
|
|
79
|
+
body.add_element(
|
|
80
|
+
ContentAST.Paragraph([
|
|
81
|
+
"Given the function ",
|
|
82
|
+
ContentAST.Equation(sp.latex(self.equation), inline=True),
|
|
83
|
+
", calculate the gradient at the point ",
|
|
84
|
+
ContentAST.Equation(format_vector(self.evaluation_point), inline=True),
|
|
85
|
+
"."
|
|
86
|
+
])
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Format evaluation point for LaTeX
|
|
90
|
+
eval_point_str = ", ".join([f"x_{i} = {self.evaluation_point[i]}" for i in range(self.num_variables)])
|
|
91
|
+
|
|
92
|
+
# For PDF: Use OnlyLatex to show gradient vector format (no answer blank)
|
|
93
|
+
body.add_element(
|
|
94
|
+
ContentAST.OnlyLatex([
|
|
95
|
+
ContentAST.Paragraph([
|
|
96
|
+
ContentAST.Equation(
|
|
97
|
+
f"\\left. \\nabla f \\right|_{{{eval_point_str}}} = ",
|
|
98
|
+
inline=True
|
|
99
|
+
)
|
|
100
|
+
])
|
|
101
|
+
])
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# For Canvas: Use OnlyHtml to show individual partial derivatives
|
|
105
|
+
for i in range(self.num_variables):
|
|
106
|
+
body.add_element(
|
|
107
|
+
ContentAST.OnlyHtml([
|
|
108
|
+
ContentAST.Paragraph([
|
|
109
|
+
ContentAST.Equation(
|
|
110
|
+
f"\\left. {self._format_partial_derivative(i)} \\right|_{{{eval_point_str}}} = ",
|
|
111
|
+
inline=True
|
|
112
|
+
),
|
|
113
|
+
ContentAST.Answer(self.answers[f"partial_derivative_{i}"])
|
|
114
|
+
])
|
|
115
|
+
])
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return body
|
|
119
|
+
|
|
120
|
+
def get_explanation(self, **kwargs) -> ContentAST.Section:
|
|
121
|
+
explanation = ContentAST.Section()
|
|
122
|
+
|
|
123
|
+
# Show the function and its gradient
|
|
124
|
+
explanation.add_element(
|
|
125
|
+
ContentAST.Paragraph([
|
|
126
|
+
"To find the gradient, we calculate the partial derivatives of ",
|
|
127
|
+
ContentAST.Equation(sp.latex(self.equation), inline=True),
|
|
128
|
+
":"
|
|
129
|
+
])
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Show analytical gradient
|
|
133
|
+
explanation.add_element(
|
|
134
|
+
ContentAST.Equation(f"\\nabla f = {sp.latex(self.gradient_function)}", inline=False)
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# Show evaluation at the specific point
|
|
138
|
+
explanation.add_element(
|
|
139
|
+
ContentAST.Paragraph([
|
|
140
|
+
f"Evaluating at the point {format_vector(self.evaluation_point)}:"
|
|
141
|
+
])
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Show each partial derivative calculation
|
|
145
|
+
subs_map = dict(zip(self.variables, self.evaluation_point))
|
|
146
|
+
for i in range(self.num_variables):
|
|
147
|
+
partial_expr = self.gradient_function[i]
|
|
148
|
+
partial_value = partial_expr.subs(subs_map)
|
|
149
|
+
|
|
150
|
+
# Use Answer.accepted_strings for clean numerical formatting
|
|
151
|
+
try:
|
|
152
|
+
numerical_value = float(partial_value)
|
|
153
|
+
except (TypeError, ValueError):
|
|
154
|
+
numerical_value = float(partial_value.evalf())
|
|
155
|
+
|
|
156
|
+
# Get clean string representation
|
|
157
|
+
clean_value = sorted(Answer.accepted_strings(numerical_value), key=lambda s: len(s))[0]
|
|
158
|
+
|
|
159
|
+
explanation.add_element(
|
|
160
|
+
ContentAST.Paragraph([
|
|
161
|
+
ContentAST.Equation(
|
|
162
|
+
f"{self._format_partial_derivative(i)} = {sp.latex(partial_expr)} = {clean_value}",
|
|
163
|
+
inline=False
|
|
164
|
+
)
|
|
165
|
+
])
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
return explanation
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@QuestionRegistry.register("DerivativeBasic")
|
|
172
|
+
class DerivativeBasic(DerivativeQuestion):
|
|
173
|
+
"""Basic derivative calculation using polynomial functions."""
|
|
174
|
+
|
|
175
|
+
def refresh(self, rng_seed=None, *args, **kwargs):
|
|
176
|
+
super().refresh(rng_seed=rng_seed, *args, **kwargs)
|
|
177
|
+
|
|
178
|
+
# Generate a basic polynomial function
|
|
179
|
+
self.variables, self.function, self.gradient_function, self.equation = generate_function(
|
|
180
|
+
self.rng, self.num_variables, self.max_degree
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
# Generate evaluation point
|
|
184
|
+
self.evaluation_point = self._generate_evaluation_point()
|
|
185
|
+
|
|
186
|
+
# Create answers
|
|
187
|
+
self._create_derivative_answers(self.evaluation_point)
|
|
188
|
+
|
|
189
|
+
# For PDF: Create single gradient vector answer
|
|
190
|
+
self._create_gradient_vector_answer()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@QuestionRegistry.register("DerivativeChain")
|
|
194
|
+
class DerivativeChain(DerivativeQuestion):
|
|
195
|
+
"""Chain rule derivative calculation using function composition."""
|
|
196
|
+
|
|
197
|
+
def refresh(self, rng_seed=None, *args, **kwargs):
|
|
198
|
+
super().refresh(rng_seed=rng_seed, *args, **kwargs)
|
|
199
|
+
|
|
200
|
+
# Try to generate a valid function/point combination, regenerating if we hit complex numbers
|
|
201
|
+
max_attempts = 10
|
|
202
|
+
for attempt in range(max_attempts):
|
|
203
|
+
try:
|
|
204
|
+
# Generate inner and outer functions for composition
|
|
205
|
+
self._generate_composed_function()
|
|
206
|
+
|
|
207
|
+
# Generate evaluation point
|
|
208
|
+
self.evaluation_point = self._generate_evaluation_point()
|
|
209
|
+
|
|
210
|
+
# Create answers - this will raise ValueError if we get complex numbers
|
|
211
|
+
self._create_derivative_answers(self.evaluation_point)
|
|
212
|
+
|
|
213
|
+
# For PDF: Create single gradient vector answer
|
|
214
|
+
self._create_gradient_vector_answer()
|
|
215
|
+
|
|
216
|
+
# If we get here, everything worked
|
|
217
|
+
break
|
|
218
|
+
|
|
219
|
+
except ValueError as e:
|
|
220
|
+
if "Complex number encountered" in str(e) and attempt < max_attempts - 1:
|
|
221
|
+
# Advance RNG state by making a dummy call
|
|
222
|
+
_ = self.rng.random()
|
|
223
|
+
continue
|
|
224
|
+
else:
|
|
225
|
+
# If we've exhausted attempts or different error, re-raise
|
|
226
|
+
raise
|
|
227
|
+
|
|
228
|
+
def _generate_composed_function(self) -> None:
|
|
229
|
+
"""Generate a composed function f(g(x)) for chain rule practice."""
|
|
230
|
+
# Create variable symbols
|
|
231
|
+
var_names = [f'x_{i}' for i in range(self.num_variables)]
|
|
232
|
+
self.variables = sp.symbols(var_names)
|
|
233
|
+
|
|
234
|
+
# Generate inner function g(x) - simpler polynomial
|
|
235
|
+
inner_terms = [m for m in sp.polys.itermonomials(self.variables, max(1, self.max_degree-1)) if m != 1]
|
|
236
|
+
coeff_pool = [*range(-5, 0), *range(1, 6)] # Smaller coefficients for inner function
|
|
237
|
+
|
|
238
|
+
if inner_terms:
|
|
239
|
+
inner_poly = sp.Add(*(self.rng.choice(coeff_pool) * t for t in inner_terms))
|
|
240
|
+
else:
|
|
241
|
+
inner_poly = sp.Add(*[self.rng.choice(coeff_pool) * v for v in self.variables])
|
|
242
|
+
|
|
243
|
+
# Generate outer function - use polynomials, exp, and ln for reliable evaluation
|
|
244
|
+
u = sp.Symbol('u') # Intermediate variable
|
|
245
|
+
outer_functions = [
|
|
246
|
+
u**2,
|
|
247
|
+
u**3,
|
|
248
|
+
u**4,
|
|
249
|
+
sp.exp(u),
|
|
250
|
+
sp.log(u + 2) # Add 2 to ensure positive argument for evaluation points
|
|
251
|
+
]
|
|
252
|
+
|
|
253
|
+
outer_func = self.rng.choice(outer_functions)
|
|
254
|
+
|
|
255
|
+
# Compose the functions: f(g(x))
|
|
256
|
+
self.inner_function = inner_poly
|
|
257
|
+
self.outer_function = outer_func
|
|
258
|
+
self.function = outer_func.subs(u, inner_poly)
|
|
259
|
+
|
|
260
|
+
# Calculate gradient using chain rule
|
|
261
|
+
self.gradient_function = sp.Matrix([self.function.diff(v) for v in self.variables])
|
|
262
|
+
|
|
263
|
+
# Create equation for display
|
|
264
|
+
f = sp.Function('f')
|
|
265
|
+
self.equation = sp.Eq(f(*self.variables), self.function)
|
|
266
|
+
|
|
267
|
+
def get_explanation(self, **kwargs) -> ContentAST.Section:
|
|
268
|
+
explanation = ContentAST.Section()
|
|
269
|
+
|
|
270
|
+
# Show the composed function structure
|
|
271
|
+
explanation.add_element(
|
|
272
|
+
ContentAST.Paragraph([
|
|
273
|
+
"This is a composition of functions requiring the chain rule. The function ",
|
|
274
|
+
ContentAST.Equation(sp.latex(self.equation), inline=True),
|
|
275
|
+
" can be written as ",
|
|
276
|
+
ContentAST.Equation(f"f(g(x)) \\text{{ where }} g(x) = {sp.latex(self.inner_function)}", inline=True),
|
|
277
|
+
"."
|
|
278
|
+
])
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
# Explain chain rule with Leibniz notation
|
|
282
|
+
explanation.add_element(
|
|
283
|
+
ContentAST.Paragraph([
|
|
284
|
+
"The chain rule states that for a composite function ",
|
|
285
|
+
ContentAST.Equation("f(g(x))", inline=True),
|
|
286
|
+
", the derivative with respect to each variable is found by multiplying the derivative of the outer function with respect to the inner function by the derivative of the inner function with respect to the variable:"
|
|
287
|
+
])
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
# Show chain rule formula for each variable
|
|
291
|
+
for i in range(self.num_variables):
|
|
292
|
+
var_name = f"x_{i}"
|
|
293
|
+
explanation.add_element(
|
|
294
|
+
ContentAST.Equation(
|
|
295
|
+
f"\\frac{{\\partial f}}{{\\partial {var_name}}} = \\frac{{\\partial f}}{{\\partial g}} \\cdot \\frac{{\\partial g}}{{\\partial {var_name}}}",
|
|
296
|
+
inline=False
|
|
297
|
+
)
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
explanation.add_element(
|
|
301
|
+
ContentAST.Paragraph([
|
|
302
|
+
"Applying this to our specific function:"
|
|
303
|
+
])
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
# Show the specific derivatives step by step
|
|
307
|
+
for i in range(self.num_variables):
|
|
308
|
+
var_name = f"x_{i}"
|
|
309
|
+
|
|
310
|
+
# Get outer function derivative with respect to inner function
|
|
311
|
+
outer_deriv = self.outer_function.diff(sp.Symbol('u'))
|
|
312
|
+
inner_deriv = self.inner_function.diff(self.variables[i])
|
|
313
|
+
|
|
314
|
+
explanation.add_element(
|
|
315
|
+
ContentAST.Paragraph([
|
|
316
|
+
f"For {var_name}:"
|
|
317
|
+
])
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
explanation.add_element(
|
|
321
|
+
ContentAST.Equation(
|
|
322
|
+
f"\\frac{{\\partial f}}{{\\partial {var_name}}} = \\left({sp.latex(outer_deriv)}\\right) \\cdot \\left({sp.latex(inner_deriv)}\\right)",
|
|
323
|
+
inline=False
|
|
324
|
+
)
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# Show analytical gradient
|
|
328
|
+
explanation.add_element(
|
|
329
|
+
ContentAST.Paragraph([
|
|
330
|
+
"This gives us the complete gradient:"
|
|
331
|
+
])
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
explanation.add_element(
|
|
335
|
+
ContentAST.Equation(f"\\nabla f = {sp.latex(self.gradient_function)}", inline=False)
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
# Show evaluation at the specific point
|
|
339
|
+
explanation.add_element(
|
|
340
|
+
ContentAST.Paragraph([
|
|
341
|
+
f"Evaluating at the point {format_vector(self.evaluation_point)}:"
|
|
342
|
+
])
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
# Show each partial derivative calculation
|
|
346
|
+
subs_map = dict(zip(self.variables, self.evaluation_point))
|
|
347
|
+
for i in range(self.num_variables):
|
|
348
|
+
partial_expr = self.gradient_function[i]
|
|
349
|
+
partial_value = partial_expr.subs(subs_map)
|
|
350
|
+
|
|
351
|
+
# Use Answer.accepted_strings for clean numerical formatting
|
|
352
|
+
try:
|
|
353
|
+
numerical_value = float(partial_value)
|
|
354
|
+
except (TypeError, ValueError):
|
|
355
|
+
numerical_value = float(partial_value.evalf())
|
|
356
|
+
|
|
357
|
+
# Get clean string representation
|
|
358
|
+
clean_value = sorted(Answer.accepted_strings(numerical_value), key=lambda s: len(s))[0]
|
|
359
|
+
|
|
360
|
+
explanation.add_element(
|
|
361
|
+
ContentAST.Paragraph([
|
|
362
|
+
ContentAST.Equation(
|
|
363
|
+
f"{self._format_partial_derivative(i)} = {sp.latex(partial_expr)} = {clean_value}",
|
|
364
|
+
inline=False
|
|
365
|
+
)
|
|
366
|
+
])
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
return explanation
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import abc
|
|
4
|
+
import logging
|
|
5
|
+
import math
|
|
6
|
+
from typing import List, Tuple, Callable, Union, Any
|
|
7
|
+
import sympy as sp
|
|
8
|
+
|
|
9
|
+
from QuizGenerator.contentast import ContentAST
|
|
10
|
+
from QuizGenerator.question import Question, Answer, QuestionRegistry
|
|
11
|
+
from QuizGenerator.mixins import TableQuestionMixin, BodyTemplatesMixin
|
|
12
|
+
|
|
13
|
+
from .misc import generate_function, format_vector
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class GradientDescentQuestion(Question, abc.ABC):
|
|
19
|
+
def __init__(self, *args, **kwargs):
|
|
20
|
+
kwargs["topic"] = kwargs.get("topic", Question.Topic.ML_OPTIMIZATION)
|
|
21
|
+
super().__init__(*args, **kwargs)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@QuestionRegistry.register("GradientDescentWalkthrough")
|
|
25
|
+
class GradientDescentWalkthrough(GradientDescentQuestion, TableQuestionMixin, BodyTemplatesMixin):
|
|
26
|
+
|
|
27
|
+
def __init__(self, *args, **kwargs):
|
|
28
|
+
super().__init__(*args, **kwargs)
|
|
29
|
+
self.num_steps = kwargs.get("num_steps", 4)
|
|
30
|
+
self.num_variables = kwargs.get("num_variables", 2)
|
|
31
|
+
self.max_degree = kwargs.get("max_degree", 2)
|
|
32
|
+
self.single_variable = kwargs.get("single_variable", False)
|
|
33
|
+
self.minimize = kwargs.get("minimize", True) # Default to minimization
|
|
34
|
+
|
|
35
|
+
if self.single_variable:
|
|
36
|
+
self.num_variables = 1
|
|
37
|
+
|
|
38
|
+
def _perform_gradient_descent(self) -> List[dict]:
|
|
39
|
+
"""
|
|
40
|
+
Perform gradient descent and return step-by-step results.
|
|
41
|
+
"""
|
|
42
|
+
results = []
|
|
43
|
+
|
|
44
|
+
x = list(map(float, self.starting_point)) # current location as floats
|
|
45
|
+
|
|
46
|
+
for step in range(self.num_steps):
|
|
47
|
+
subs_map = dict(zip(self.variables, x))
|
|
48
|
+
|
|
49
|
+
# gradient as floats
|
|
50
|
+
g_syms = self.gradient_function.subs(subs_map)
|
|
51
|
+
g = [float(val) for val in g_syms]
|
|
52
|
+
|
|
53
|
+
# function value
|
|
54
|
+
f_val = float(self.function.subs(subs_map))
|
|
55
|
+
|
|
56
|
+
update = [self.learning_rate * gi for gi in g]
|
|
57
|
+
|
|
58
|
+
results.append(
|
|
59
|
+
{
|
|
60
|
+
"step": step + 1,
|
|
61
|
+
"location": x[:],
|
|
62
|
+
"gradient": g[:],
|
|
63
|
+
"update": update[:],
|
|
64
|
+
"function_value": f_val,
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
x = [xi - ui for xi, ui in zip(x, update)] if self.minimize else \
|
|
69
|
+
[xi + ui for xi, ui in zip(x, update)]
|
|
70
|
+
|
|
71
|
+
return results
|
|
72
|
+
|
|
73
|
+
def refresh(self, rng_seed=None, *args, **kwargs):
|
|
74
|
+
super().refresh(rng_seed=rng_seed, *args, **kwargs)
|
|
75
|
+
|
|
76
|
+
# Generate function and its properties
|
|
77
|
+
self.variables, self.function, self.gradient_function, self.equation = generate_function(self.rng, self.num_variables, self.max_degree)
|
|
78
|
+
|
|
79
|
+
# Generate learning rate (expanded range)
|
|
80
|
+
self.learning_rate = self.rng.choice([0.1, 0.15, 0.2, 0.25, 0.3, 0.35, 0.4, 0.45, 0.5])
|
|
81
|
+
|
|
82
|
+
self.starting_point = [self.rng.randint(-3, 3) for _ in range(self.num_variables)]
|
|
83
|
+
|
|
84
|
+
# Perform gradient descent
|
|
85
|
+
self.gradient_descent_results = self._perform_gradient_descent()
|
|
86
|
+
|
|
87
|
+
# Set up answers
|
|
88
|
+
self.answers = {}
|
|
89
|
+
|
|
90
|
+
# Answers for each step
|
|
91
|
+
for i, result in enumerate(self.gradient_descent_results):
|
|
92
|
+
step = result['step']
|
|
93
|
+
|
|
94
|
+
# Location answer
|
|
95
|
+
location_key = f"answer__location_{step}"
|
|
96
|
+
self.answers[location_key] = Answer.vector_value(location_key, list(result['location']))
|
|
97
|
+
|
|
98
|
+
# Gradient answer
|
|
99
|
+
gradient_key = f"answer__gradient_{step}"
|
|
100
|
+
self.answers[gradient_key] = Answer.vector_value(gradient_key, list(result['gradient']))
|
|
101
|
+
|
|
102
|
+
# Update answer
|
|
103
|
+
update_key = f"answer__update_{step}"
|
|
104
|
+
self.answers[update_key] = Answer.vector_value(update_key, list(result['update']))
|
|
105
|
+
|
|
106
|
+
def get_body(self, **kwargs) -> ContentAST.Section:
|
|
107
|
+
body = ContentAST.Section()
|
|
108
|
+
|
|
109
|
+
# Introduction
|
|
110
|
+
objective = "minimize" if self.minimize else "maximize"
|
|
111
|
+
sign = "-" if self.minimize else "+"
|
|
112
|
+
|
|
113
|
+
body.add_element(
|
|
114
|
+
ContentAST.Paragraph(
|
|
115
|
+
[
|
|
116
|
+
f"Use gradient descent to {objective} the function ",
|
|
117
|
+
ContentAST.Equation(sp.latex(self.function), inline=True),
|
|
118
|
+
" with learning rate ",
|
|
119
|
+
ContentAST.Equation(f"\\alpha = {self.learning_rate}", inline=True),
|
|
120
|
+
f" and starting point {self.starting_point[0] if self.num_variables == 1 else tuple(self.starting_point)}. "
|
|
121
|
+
"Fill in the table below with your calculations."
|
|
122
|
+
]
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# Create table data - use ContentAST.Equation for proper LaTeX rendering in headers
|
|
127
|
+
headers = [
|
|
128
|
+
"n",
|
|
129
|
+
"location",
|
|
130
|
+
ContentAST.Equation("\\nabla f", inline=True),
|
|
131
|
+
ContentAST.Equation("\\alpha \\cdot \\nabla f", inline=True)
|
|
132
|
+
]
|
|
133
|
+
table_rows = []
|
|
134
|
+
|
|
135
|
+
for i in range(self.num_steps):
|
|
136
|
+
step = i + 1
|
|
137
|
+
row = {"n": str(step)}
|
|
138
|
+
|
|
139
|
+
if step == 1:
|
|
140
|
+
|
|
141
|
+
# Fill in starting location for first row with default formatting
|
|
142
|
+
row["location"] = f"{format_vector(self.starting_point)}"
|
|
143
|
+
row[headers[2]] = f"answer__gradient_{step}" # gradient column
|
|
144
|
+
row[headers[3]] = f"answer__update_{step}" # update column
|
|
145
|
+
else:
|
|
146
|
+
# Subsequent rows - all answer fields
|
|
147
|
+
row["location"] = f"answer__location_{step}"
|
|
148
|
+
row[headers[2]] = f"answer__gradient_{step}" # gradient column
|
|
149
|
+
row[headers[3]] = f"answer__update_{step}" # update column
|
|
150
|
+
table_rows.append(row)
|
|
151
|
+
|
|
152
|
+
# Create the table using mixin
|
|
153
|
+
gradient_table = self.create_answer_table(
|
|
154
|
+
headers=headers,
|
|
155
|
+
data_rows=table_rows,
|
|
156
|
+
answer_columns=["location", headers[2], headers[3]] # Use actual header objects
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
body.add_element(gradient_table)
|
|
160
|
+
|
|
161
|
+
return body
|
|
162
|
+
|
|
163
|
+
def get_explanation(self, **kwargs) -> ContentAST.Section:
|
|
164
|
+
explanation = ContentAST.Section()
|
|
165
|
+
|
|
166
|
+
explanation.add_element(
|
|
167
|
+
ContentAST.Paragraph(
|
|
168
|
+
[
|
|
169
|
+
"Gradient descent is an optimization algorithm that iteratively moves towards "
|
|
170
|
+
"the minimum of a function by taking steps proportional to the negative of the gradient."
|
|
171
|
+
]
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
objective = "minimize" if self.minimize else "maximize"
|
|
176
|
+
sign = "-" if self.minimize else "+"
|
|
177
|
+
|
|
178
|
+
explanation.add_element(
|
|
179
|
+
ContentAST.Paragraph(
|
|
180
|
+
[
|
|
181
|
+
f"We want to {objective} the function ",
|
|
182
|
+
ContentAST.Equation(sp.latex(self.function), inline=True),
|
|
183
|
+
". First, we calculate the analytical gradient:"
|
|
184
|
+
]
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Add analytical gradient calculation as a display equation (vertical vector)
|
|
189
|
+
explanation.add_element(
|
|
190
|
+
ContentAST.Equation(f"\\nabla f = {sp.latex(self.gradient_function)}", inline=False)
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
explanation.add_element(
|
|
194
|
+
ContentAST.Paragraph(
|
|
195
|
+
[
|
|
196
|
+
f"Since we want to {objective}, we use the update rule: ",
|
|
197
|
+
ContentAST.Equation(f"x_{{new}} = x_{{old}} {sign} \\alpha \\nabla f", inline=True),
|
|
198
|
+
f". We start at {tuple(self.starting_point)} with learning rate ",
|
|
199
|
+
ContentAST.Equation(f"\\alpha = {self.learning_rate}", inline=True),
|
|
200
|
+
"."
|
|
201
|
+
]
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
# Add completed table showing all solutions
|
|
206
|
+
explanation.add_element(
|
|
207
|
+
ContentAST.Paragraph(
|
|
208
|
+
[
|
|
209
|
+
"**Solution Table:**"
|
|
210
|
+
]
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
# Create filled solution table
|
|
215
|
+
solution_headers = [
|
|
216
|
+
"n",
|
|
217
|
+
"location",
|
|
218
|
+
ContentAST.Equation("\\nabla f", inline=True),
|
|
219
|
+
ContentAST.Equation("\\alpha \\cdot \\nabla f", inline=True)
|
|
220
|
+
]
|
|
221
|
+
|
|
222
|
+
solution_rows = []
|
|
223
|
+
for i, result in enumerate(self.gradient_descent_results):
|
|
224
|
+
step = result['step']
|
|
225
|
+
row = {"n": str(step)}
|
|
226
|
+
|
|
227
|
+
row["location"] = f"{format_vector(result['location'])}"
|
|
228
|
+
row[solution_headers[2]] = f"{format_vector(result['gradient'])}"
|
|
229
|
+
row[solution_headers[3]] = f"{format_vector(result['update'])}"
|
|
230
|
+
|
|
231
|
+
solution_rows.append(row)
|
|
232
|
+
|
|
233
|
+
# Create solution table (non-answer table, just display)
|
|
234
|
+
solution_table = self.create_answer_table(
|
|
235
|
+
headers=solution_headers,
|
|
236
|
+
data_rows=solution_rows,
|
|
237
|
+
answer_columns=[] # No answer columns since this is just for display
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
explanation.add_element(solution_table)
|
|
241
|
+
|
|
242
|
+
# Step-by-step explanation
|
|
243
|
+
for i, result in enumerate(self.gradient_descent_results):
|
|
244
|
+
step = result['step']
|
|
245
|
+
|
|
246
|
+
explanation.add_element(
|
|
247
|
+
ContentAST.Paragraph(
|
|
248
|
+
[
|
|
249
|
+
f"**Step {step}:**"
|
|
250
|
+
]
|
|
251
|
+
)
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
explanation.add_element(
|
|
255
|
+
ContentAST.Paragraph(
|
|
256
|
+
[
|
|
257
|
+
f"Location: {format_vector(result['location'])}"
|
|
258
|
+
]
|
|
259
|
+
)
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
explanation.add_element(
|
|
263
|
+
ContentAST.Paragraph(
|
|
264
|
+
[
|
|
265
|
+
f"Gradient: {format_vector(result['gradient'])}"
|
|
266
|
+
]
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
explanation.add_element(
|
|
271
|
+
ContentAST.Paragraph(
|
|
272
|
+
[
|
|
273
|
+
"Update: ",
|
|
274
|
+
ContentAST.Equation(
|
|
275
|
+
f"\\alpha \\cdot \\nabla f = {self.learning_rate} \\cdot {format_vector(result['gradient'])} = {format_vector(result['update'])}",
|
|
276
|
+
inline=True
|
|
277
|
+
)
|
|
278
|
+
]
|
|
279
|
+
)
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
if step < len(self.gradient_descent_results):
|
|
283
|
+
# Calculate next location for display
|
|
284
|
+
current_loc = result['location']
|
|
285
|
+
update = result['update']
|
|
286
|
+
next_loc = [current_loc[j] - update[j] for j in range(len(current_loc))]
|
|
287
|
+
|
|
288
|
+
explanation.add_element(
|
|
289
|
+
ContentAST.Paragraph(
|
|
290
|
+
[
|
|
291
|
+
f"Next location: {format_vector(current_loc)} - {format_vector(result['update'])} = {format_vector(next_loc)}"
|
|
292
|
+
]
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
function_values = [r['function_value'] for r in self.gradient_descent_results]
|
|
297
|
+
explanation.add_element(
|
|
298
|
+
ContentAST.Paragraph(
|
|
299
|
+
[
|
|
300
|
+
f"Function values: {[f'{v:.4f}' for v in function_values]}"
|
|
301
|
+
]
|
|
302
|
+
)
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
return explanation
|