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.
Files changed (52) hide show
  1. QuizGenerator/README.md +5 -0
  2. QuizGenerator/__init__.py +27 -0
  3. QuizGenerator/__main__.py +7 -0
  4. QuizGenerator/canvas/__init__.py +13 -0
  5. QuizGenerator/canvas/canvas_interface.py +627 -0
  6. QuizGenerator/canvas/classes.py +235 -0
  7. QuizGenerator/constants.py +149 -0
  8. QuizGenerator/contentast.py +1955 -0
  9. QuizGenerator/generate.py +253 -0
  10. QuizGenerator/logging.yaml +55 -0
  11. QuizGenerator/misc.py +579 -0
  12. QuizGenerator/mixins.py +548 -0
  13. QuizGenerator/performance.py +202 -0
  14. QuizGenerator/premade_questions/__init__.py +0 -0
  15. QuizGenerator/premade_questions/basic.py +103 -0
  16. QuizGenerator/premade_questions/cst334/__init__.py +1 -0
  17. QuizGenerator/premade_questions/cst334/languages.py +391 -0
  18. QuizGenerator/premade_questions/cst334/math_questions.py +297 -0
  19. QuizGenerator/premade_questions/cst334/memory_questions.py +1400 -0
  20. QuizGenerator/premade_questions/cst334/ostep13_vsfs.py +572 -0
  21. QuizGenerator/premade_questions/cst334/persistence_questions.py +451 -0
  22. QuizGenerator/premade_questions/cst334/process.py +648 -0
  23. QuizGenerator/premade_questions/cst463/__init__.py +0 -0
  24. QuizGenerator/premade_questions/cst463/gradient_descent/__init__.py +3 -0
  25. QuizGenerator/premade_questions/cst463/gradient_descent/gradient_calculation.py +369 -0
  26. QuizGenerator/premade_questions/cst463/gradient_descent/gradient_descent_questions.py +305 -0
  27. QuizGenerator/premade_questions/cst463/gradient_descent/loss_calculations.py +650 -0
  28. QuizGenerator/premade_questions/cst463/gradient_descent/misc.py +73 -0
  29. QuizGenerator/premade_questions/cst463/math_and_data/__init__.py +2 -0
  30. QuizGenerator/premade_questions/cst463/math_and_data/matrix_questions.py +631 -0
  31. QuizGenerator/premade_questions/cst463/math_and_data/vector_questions.py +534 -0
  32. QuizGenerator/premade_questions/cst463/models/__init__.py +0 -0
  33. QuizGenerator/premade_questions/cst463/models/attention.py +192 -0
  34. QuizGenerator/premade_questions/cst463/models/cnns.py +186 -0
  35. QuizGenerator/premade_questions/cst463/models/matrices.py +24 -0
  36. QuizGenerator/premade_questions/cst463/models/rnns.py +202 -0
  37. QuizGenerator/premade_questions/cst463/models/text.py +203 -0
  38. QuizGenerator/premade_questions/cst463/models/weight_counting.py +227 -0
  39. QuizGenerator/premade_questions/cst463/neural-network-basics/__init__.py +6 -0
  40. QuizGenerator/premade_questions/cst463/neural-network-basics/neural_network_questions.py +1314 -0
  41. QuizGenerator/premade_questions/cst463/tensorflow-intro/__init__.py +6 -0
  42. QuizGenerator/premade_questions/cst463/tensorflow-intro/tensorflow_questions.py +936 -0
  43. QuizGenerator/qrcode_generator.py +293 -0
  44. QuizGenerator/question.py +715 -0
  45. QuizGenerator/quiz.py +467 -0
  46. QuizGenerator/regenerate.py +472 -0
  47. QuizGenerator/typst_utils.py +113 -0
  48. quizgenerator-0.4.2.dist-info/METADATA +265 -0
  49. quizgenerator-0.4.2.dist-info/RECORD +52 -0
  50. quizgenerator-0.4.2.dist-info/WHEEL +4 -0
  51. quizgenerator-0.4.2.dist-info/entry_points.txt +3 -0
  52. quizgenerator-0.4.2.dist-info/licenses/LICENSE +674 -0
@@ -0,0 +1,192 @@
1
+ import abc
2
+ import logging
3
+ import math
4
+ import keras
5
+ import numpy as np
6
+
7
+ from QuizGenerator.misc import MatrixAnswer
8
+ from QuizGenerator.question import Question, QuestionRegistry, Answer
9
+ from QuizGenerator.contentast import ContentAST
10
+ from QuizGenerator.constants import MathRanges
11
+ from QuizGenerator.mixins import TableQuestionMixin
12
+
13
+ from .matrices import MatrixQuestion
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+
18
+ @QuestionRegistry.register("cst463.attention.forward-pass")
19
+ class AttentionForwardPass(MatrixQuestion, TableQuestionMixin):
20
+
21
+ @staticmethod
22
+ def simple_attention(Q, K, V):
23
+ """
24
+ Q: (seq_len, d_k) - queries
25
+ K: (seq_len, d_k) - keys
26
+ V: (seq_len, d_v) - values
27
+
28
+ Returns: (seq_len, d_v) - attended output
29
+ """
30
+ d_k = Q.shape[1]
31
+
32
+ # Compute attention scores
33
+ scores = Q @ K.T / np.sqrt(d_k)
34
+
35
+ # Softmax to get weights
36
+ attention_weights = np.exp(scores) / np.exp(scores).sum(axis=1, keepdims=True)
37
+
38
+ # Weighted sum of values
39
+ output = attention_weights @ V
40
+
41
+ return output, attention_weights
42
+
43
+ def refresh(self, *args, **kwargs):
44
+ super().refresh(*args, **kwargs)
45
+
46
+ seq_len = kwargs.get("seq_len", 3)
47
+ d_k = kwargs.get("key_dimension", 1) # key/query dimension
48
+ d_v = kwargs.get("value_dimension", 1) # value dimension
49
+
50
+ # Small integer matrices
51
+ self.Q = self.rng.randint(0, 3, size=(seq_len, d_k))
52
+ self.K = self.rng.randint(0, 3, size=(seq_len, d_k))
53
+ self.V = self.rng.randint(0, 3, size=(seq_len, d_v))
54
+
55
+ self.Q = self.get_rounded_matrix((seq_len, d_k), 0, 3)
56
+ self.K = self.get_rounded_matrix((seq_len, d_k), 0, 3)
57
+ self.V = self.get_rounded_matrix((seq_len, d_v), 0, 3)
58
+
59
+ self.output, self.weights = self.simple_attention(self.Q, self.K, self.V)
60
+
61
+ ## Answers:
62
+ # Q, K, V, output, weights
63
+
64
+ self.answers["weights"] = MatrixAnswer("weights", self.output)
65
+ self.answers["output"] = MatrixAnswer("output", self.output)
66
+
67
+ return True
68
+
69
+ def get_body(self, **kwargs) -> ContentAST.Section:
70
+ body = ContentAST.Section()
71
+
72
+ body.add_element(
73
+ ContentAST.Text("Given the below information about a self attention layer, please calculate the output sequence.")
74
+ )
75
+ body.add_element(
76
+ self.create_info_table(
77
+ {
78
+ "Q": ContentAST.Matrix(self.Q),
79
+ "K": ContentAST.Matrix(self.K),
80
+ "V": ContentAST.Matrix(self.V),
81
+ }
82
+ )
83
+ )
84
+
85
+ body.add_elements([
86
+ ContentAST.LineBreak(),
87
+ self.answers["weights"].get_ast_element(label=f"Weights"),
88
+ ContentAST.LineBreak(),
89
+ self.answers["output"].get_ast_element(label=f"Output"),
90
+ ])
91
+
92
+ return body
93
+
94
+ def get_explanation(self, **kwargs) -> ContentAST.Section:
95
+ explanation = ContentAST.Section()
96
+ digits = Answer.DEFAULT_ROUNDING_DIGITS
97
+
98
+ explanation.add_element(
99
+ ContentAST.Paragraph([
100
+ "Self-attention uses scaled dot-product attention to compute a weighted combination of values based on query-key similarity."
101
+ ])
102
+ )
103
+
104
+ # Step 1: Compute attention scores
105
+ explanation.add_element(
106
+ ContentAST.Paragraph([
107
+ ContentAST.Text("Step 1: Compute attention scores", emphasis=True)
108
+ ])
109
+ )
110
+
111
+ d_k = self.Q.shape[1]
112
+ explanation.add_element(
113
+ ContentAST.Equation(f"\\text{{scores}} = \\frac{{Q K^T}}{{\\sqrt{{d_k}}}} = \\frac{{Q K^T}}{{\\sqrt{{{d_k}}}}}")
114
+ )
115
+
116
+ scores = self.Q @ self.K.T / np.sqrt(d_k)
117
+
118
+ explanation.add_element(
119
+ ContentAST.Paragraph([
120
+ "Raw scores (scaling by ",
121
+ ContentAST.Equation(f'\\sqrt{{{d_k}}}', inline=True),
122
+ " prevents extremely large values):"
123
+ ])
124
+ )
125
+ explanation.add_element(ContentAST.Matrix(np.round(scores, digits)))
126
+
127
+ # Step 2: Apply softmax
128
+ explanation.add_element(
129
+ ContentAST.Paragraph([
130
+ ContentAST.Text("Step 2: Apply softmax to get attention weights", emphasis=True)
131
+ ])
132
+ )
133
+
134
+ explanation.add_element(
135
+ ContentAST.Equation(r"\alpha_{ij} = \frac{\exp(\text{score}_{ij})}{\sum_k \exp(\text{score}_{ik})}")
136
+ )
137
+
138
+ # Show ONE example row
139
+ explanation.add_element(
140
+ ContentAST.Paragraph([
141
+ "Example: Row 0 softmax computation"
142
+ ])
143
+ )
144
+
145
+ row_scores = scores[0]
146
+ exp_scores = np.exp(row_scores)
147
+ sum_exp = exp_scores.sum()
148
+ weights_row = exp_scores / sum_exp
149
+
150
+ exp_terms = " + ".join([f"e^{{{s:.{digits}f}}}" for s in row_scores])
151
+
152
+ explanation.add_element(
153
+ ContentAST.Paragraph([
154
+ f"Denominator = {exp_terms} = {sum_exp:.{digits}f}"
155
+ ])
156
+ )
157
+
158
+ # Format array with proper rounding
159
+ weights_str = "[" + ", ".join([f"{w:.{digits}f}" for w in weights_row]) + "]"
160
+ explanation.add_element(
161
+ ContentAST.Paragraph([
162
+ f"Resulting weights: {weights_str}"
163
+ ])
164
+ )
165
+
166
+ explanation.add_element(
167
+ ContentAST.Paragraph([
168
+ "Complete attention weight matrix:"
169
+ ])
170
+ )
171
+ explanation.add_element(ContentAST.Matrix(np.round(self.weights, digits)))
172
+
173
+ # Step 3: Weighted sum of values
174
+ explanation.add_element(
175
+ ContentAST.Paragraph([
176
+ ContentAST.Text("Step 3: Compute weighted sum of values", emphasis=True)
177
+ ])
178
+ )
179
+
180
+ explanation.add_element(
181
+ ContentAST.Equation(r"\text{output} = \text{weights} \times V")
182
+ )
183
+
184
+ explanation.add_element(
185
+ ContentAST.Paragraph([
186
+ "Final output:"
187
+ ])
188
+ )
189
+ explanation.add_element(ContentAST.Matrix(np.round(self.output, digits)))
190
+
191
+ return explanation
192
+
@@ -0,0 +1,186 @@
1
+ import abc
2
+ import logging
3
+ import math
4
+ import keras
5
+ import numpy as np
6
+
7
+ from QuizGenerator.question import Question, QuestionRegistry
8
+ from QuizGenerator.misc import Answer, MatrixAnswer
9
+ from QuizGenerator.contentast import ContentAST
10
+ from QuizGenerator.constants import MathRanges
11
+ from .matrices import MatrixQuestion
12
+
13
+ log = logging.getLogger(__name__)
14
+
15
+
16
+ @QuestionRegistry.register("cst463.cnn.convolution")
17
+ class ConvolutionCalculation(MatrixQuestion):
18
+
19
+ @staticmethod
20
+ def conv2d_multi_channel(image, kernel, stride=1, padding=0):
21
+ """
22
+ image: (H, W, C_in) - height, width, input channels
23
+ kernel: (K_h, K_w, C_in, C_out) - kernel height, width, input channels, output filters
24
+ Returns: (H_out, W_out, C_out)
25
+ """
26
+ H, W = image.shape
27
+ K_h, K_w, C_out = kernel.shape
28
+
29
+ # Add padding
30
+ if padding > 0:
31
+ image = np.pad(image, ((padding, padding), (padding, padding)), mode='constant')
32
+ H, W = H + 2 * padding, W + 2 * padding
33
+
34
+ # Output dimensions
35
+ H_out = (H - K_h) // stride + 1
36
+ W_out = (W - K_w) // stride + 1
37
+
38
+ output = np.zeros((H_out, W_out, C_out))
39
+
40
+ # Convolve each filter
41
+ for f in range(C_out):
42
+ for i in range(H_out):
43
+ for j in range(W_out):
44
+ h_start = i * stride
45
+ w_start = j * stride
46
+ # Extract receptive field and sum over all input channels
47
+ receptive_field = image[h_start:h_start + K_h, w_start:w_start + K_w]
48
+ output[i, j, f] = np.sum(receptive_field * kernel[:, :, f])
49
+
50
+ return output
51
+
52
+ def refresh(self, *args, **kwargs):
53
+ super().refresh(*args, **kwargs)
54
+
55
+ # num_input_channels = 1
56
+ input_size = kwargs.get("input_size", 4)
57
+ num_filters = kwargs.get("num_filters", 1)
58
+ self.stride = kwargs.get("stride", 1)
59
+ self.padding = kwargs.get("padding", 0)
60
+
61
+ # Small sizes for hand calculation
62
+ self.image = self.get_rounded_matrix((input_size, input_size))
63
+ self.kernel = self.get_rounded_matrix((3, 3, num_filters), -1, 1)
64
+
65
+ self.result = self.conv2d_multi_channel(self.image, self.kernel, stride=self.stride, padding=self.padding)
66
+
67
+ self.answers = {
68
+ f"result_{i}" : MatrixAnswer(f"result_{i}", self.result[:,:,i])
69
+ for i in range(self.result.shape[-1])
70
+ }
71
+
72
+ return True
73
+
74
+ def get_body(self, **kwargs) -> ContentAST.Section:
75
+ body = ContentAST.Section()
76
+
77
+ body.add_elements(
78
+ [
79
+ ContentAST.Text("Given image represented as matrix: "),
80
+ ContentAST.Matrix(self.image, name="image")
81
+ ]
82
+ )
83
+
84
+ body.add_elements(
85
+ [
86
+ ContentAST.Text("And convolution filters: "),
87
+ ] + [
88
+ ContentAST.Matrix(self.kernel[:, :, i], name=f"Filter {i}")
89
+ for i in range(self.kernel.shape[-1])
90
+ ]
91
+ )
92
+
93
+ body.add_element(
94
+ ContentAST.Paragraph(
95
+ [
96
+ f"Calculate the output of the convolution operation. Assume stride = {self.stride} and padding = {self.padding}."
97
+ ]
98
+ )
99
+ )
100
+
101
+ body.add_element(ContentAST.LineBreak())
102
+
103
+ body.add_elements([
104
+ ContentAST.Container([
105
+ self.answers[f"result_{i}"].get_ast_element(label=f"Result of filter {i}"),
106
+ ContentAST.LineBreak()
107
+ ])
108
+ for i in range(self.result.shape[-1])
109
+ ])
110
+
111
+
112
+
113
+ return body
114
+
115
+ def get_explanation(self, **kwargs) -> ContentAST.Section:
116
+ explanation = ContentAST.Section()
117
+ digits = Answer.DEFAULT_ROUNDING_DIGITS
118
+
119
+ explanation.add_element(
120
+ ContentAST.Paragraph([
121
+ "To compute a 2D convolution, we slide the filter across the input image and compute the element-wise product at each position, then sum the results."
122
+ ])
123
+ )
124
+
125
+ explanation.add_element(
126
+ ContentAST.Paragraph([
127
+ f"With stride={self.stride} and padding={self.padding}: ",
128
+ f"stride controls how many pixels the filter moves each step, ",
129
+ f"and padding adds zeros around the border {'(no border in this case)' if self.padding == 0 else f'({self.padding} pixels)'}."
130
+ ])
131
+ )
132
+
133
+ # For each filter, show one detailed example computation
134
+ for f_idx in range(self.kernel.shape[-1]):
135
+ explanation.add_element(
136
+ ContentAST.Paragraph([
137
+ ContentAST.Text(f"Filter {f_idx}:", emphasis=True)
138
+ ])
139
+ )
140
+
141
+ # Show the filter (rounded)
142
+ explanation.add_element(
143
+ ContentAST.Matrix(np.round(self.kernel[:, :, f_idx], digits), name=f"Filter {f_idx}")
144
+ )
145
+
146
+ # Show ONE example computation (position 0,0)
147
+ explanation.add_element(
148
+ ContentAST.Paragraph([
149
+ "Example computation at position (0, 0):"
150
+ ])
151
+ )
152
+
153
+ # Account for padding when extracting receptive field
154
+ if self.padding > 0:
155
+ padded_image = np.pad(self.image, ((self.padding, self.padding), (self.padding, self.padding)), mode='constant')
156
+ receptive_field = padded_image[0:3, 0:3]
157
+ else:
158
+ receptive_field = self.image[0:3, 0:3]
159
+
160
+ computation_steps = []
161
+ for r in range(3):
162
+ row_terms = []
163
+ for c in range(3):
164
+ img_val = receptive_field[r, c]
165
+ kernel_val = self.kernel[r, c, f_idx]
166
+ row_terms.append(f"({img_val:.2f} \\times {kernel_val:.2f})")
167
+ computation_steps.append(" + ".join(row_terms))
168
+
169
+ equation_str = " + ".join(computation_steps)
170
+ result_val = self.result[0, 0, f_idx]
171
+
172
+ explanation.add_element(
173
+ ContentAST.Equation(f"{equation_str} = {result_val:.2f}")
174
+ )
175
+
176
+ # Show the complete output matrix (rounded)
177
+ explanation.add_element(
178
+ ContentAST.Paragraph([
179
+ "Complete output:"
180
+ ])
181
+ )
182
+ explanation.add_element(
183
+ ContentAST.Matrix(np.round(self.result[:, :, f_idx], digits))
184
+ )
185
+
186
+ return explanation
@@ -0,0 +1,24 @@
1
+ #!env python
2
+ import abc
3
+
4
+ import numpy as np
5
+
6
+ from QuizGenerator.question import Question
7
+
8
+
9
+ class MatrixQuestion(Question, abc.ABC):
10
+ def __init__(self, *args, **kwargs):
11
+ super().__init__(*args, **kwargs)
12
+ self.default_digits_to_round = kwargs.get("digits_to_round", 2)
13
+
14
+ def refresh(self, rng_seed=None, *args, **kwargs):
15
+ super().refresh(rng_seed=rng_seed, *args, **kwargs)
16
+ self.rng = np.random.RandomState(rng_seed)
17
+
18
+ def get_rounded_matrix(self, shape, low=0, high=1, digits_to_round=None):
19
+ if digits_to_round is None:
20
+ digits_to_round = self.default_digits_to_round
21
+ return np.round(
22
+ (high - low) * self.rng.rand(*shape) + low,
23
+ digits_to_round
24
+ )
@@ -0,0 +1,202 @@
1
+ import abc
2
+ import logging
3
+ import math
4
+ import keras
5
+ import numpy as np
6
+
7
+ from .matrices import MatrixQuestion
8
+ from QuizGenerator.question import Question, QuestionRegistry, Answer
9
+ from QuizGenerator.contentast import ContentAST
10
+ from QuizGenerator.constants import MathRanges
11
+ from QuizGenerator.mixins import TableQuestionMixin
12
+
13
+ log = logging.getLogger(__name__)
14
+
15
+
16
+ @QuestionRegistry.register("cst463.rnn.forward-pass")
17
+ class RNNForwardPass(MatrixQuestion, TableQuestionMixin):
18
+
19
+ @staticmethod
20
+ def rnn_forward(x_seq, W_xh, W_hh, b_h, h_0, activation='tanh'):
21
+ """
22
+ x_seq: (seq_len, input_dim) - input sequence
23
+ W_xh: (input_dim, hidden_dim) - input to hidden weights
24
+ W_hh: (hidden_dim, hidden_dim) - hidden to hidden weights
25
+ b_h: (hidden_dim,) - hidden bias
26
+ h_0: (hidden_dim,) - initial hidden state
27
+
28
+ Returns: all hidden states (seq_len, hidden_dim)
29
+ """
30
+ seq_len = len(x_seq)
31
+ hidden_dim = W_hh.shape[0]
32
+
33
+ h_states = np.zeros((seq_len, hidden_dim))
34
+ h_t = h_0
35
+
36
+ for t in range(seq_len):
37
+ h_t = x_seq[t] @ W_xh + h_t @ W_hh + b_h
38
+ if activation == 'tanh':
39
+ h_t = np.tanh(h_t)
40
+ elif activation == 'relu':
41
+ h_t = np.maximum(0, h_t)
42
+ h_states[t] = h_t
43
+
44
+ return h_states
45
+
46
+ def refresh(self, *args, **kwargs):
47
+ super().refresh(*args, **kwargs)
48
+ self.rng = np.random.RandomState(kwargs.get("rng_seed", None))
49
+
50
+ seq_len = kwargs.get("seq_len", 3)
51
+ input_dim = kwargs.get("input_dim", 1)
52
+ hidden_dim = kwargs.get("hidden_dim", 1)
53
+
54
+ # Small integer weights for hand calculation
55
+ self.x_seq = self.get_rounded_matrix((seq_len, input_dim)) # self.rng.randint(0, 3, size=(seq_len, input_dim))
56
+ self.W_xh = self.get_rounded_matrix((input_dim, hidden_dim), -1, 2)
57
+ self.W_hh = self.get_rounded_matrix((hidden_dim, hidden_dim), -1, 2)
58
+ self.b_h = self.get_rounded_matrix((hidden_dim,), -1, 2)
59
+ self.h_0 = np.zeros(hidden_dim)
60
+
61
+ self.h_states = self.rnn_forward(self.x_seq, self.W_xh, self.W_hh, self.b_h, self.h_0) #.reshape((seq_len,-1))
62
+
63
+ ## Answers:
64
+ # x_seq, W_xh, W_hh, b_h, h_0, h_states
65
+
66
+ self.answers["output_sequence"] = Answer.matrix(key="output_sequence", value=self.h_states)
67
+
68
+ return True
69
+
70
+ def get_body(self, **kwargs) -> ContentAST.Section:
71
+ body = ContentAST.Section()
72
+
73
+ body.add_element(
74
+ ContentAST.Paragraph([
75
+ ContentAST.Text("Given the below information about an RNN, please calculate the output sequence."),
76
+ "Assume that you are using a tanh activation function."
77
+ ])
78
+ )
79
+ body.add_element(
80
+ self.create_info_table(
81
+ {
82
+ ContentAST.Container(["Input sequence, ", ContentAST.Equation("x_{seq}", inline=True)]) : ContentAST.Matrix(self.x_seq),
83
+ ContentAST.Container(["Input weights, ", ContentAST.Equation("W_{xh}", inline=True)]) : ContentAST.Matrix(self.W_xh),
84
+ ContentAST.Container(["Hidden weights, ", ContentAST.Equation("W_{hh}", inline=True)]) : ContentAST.Matrix(self.W_hh),
85
+ ContentAST.Container(["Bias, ", ContentAST.Equation("b_{h}", inline=True)]) : ContentAST.Matrix(self.b_h),
86
+ ContentAST.Container(["Hidden states, ", ContentAST.Equation("h_{0}", inline=True)]) : ContentAST.Matrix(self.h_0),
87
+ }
88
+ )
89
+ )
90
+
91
+ body.add_element(ContentAST.LineBreak())
92
+
93
+ body.add_element(
94
+ self.answers["output_sequence"].get_ast_element(label=f"Hidden states")
95
+ )
96
+
97
+ return body
98
+
99
+ def get_explanation(self, **kwargs) -> ContentAST.Section:
100
+ explanation = ContentAST.Section()
101
+ digits = Answer.DEFAULT_ROUNDING_DIGITS
102
+
103
+ explanation.add_element(
104
+ ContentAST.Paragraph([
105
+ "For an RNN forward pass, we compute the hidden state at each time step using:"
106
+ ])
107
+ )
108
+
109
+ explanation.add_element(
110
+ ContentAST.Equation(r"h_t = \tanh(x_t W_{xh} + h_{t-1} W_{hh} + b_h)")
111
+ )
112
+
113
+ explanation.add_element(
114
+ ContentAST.Paragraph([
115
+ "Where the input contributes via ", ContentAST.Equation("W_{xh}", inline=True),
116
+ ", the previous hidden state contributes via ", ContentAST.Equation("W_{hh}", inline=True),
117
+ ", and ", ContentAST.Equation("b_h", inline=True), " is the bias."
118
+ ])
119
+ )
120
+
121
+ # Format arrays with proper rounding
122
+ def format_array(arr):
123
+ from QuizGenerator.misc import fix_negative_zero
124
+ if arr.ndim == 0:
125
+ return f"{fix_negative_zero(arr):.{digits}f}"
126
+ return "[" + ", ".join([f"{fix_negative_zero(x):.{digits}f}" for x in arr.flatten()]) + "]"
127
+
128
+ # Show detailed examples for first 2 timesteps (or just 1 if seq_len == 1)
129
+ seq_len = len(self.x_seq)
130
+ num_examples = min(2, seq_len)
131
+
132
+ explanation.add_element(ContentAST.Paragraph([""]))
133
+
134
+ for t in range(num_examples):
135
+ explanation.add_element(
136
+ ContentAST.Paragraph([
137
+ ContentAST.Text(f"Example: Timestep {t}", emphasis=True)
138
+ ])
139
+ )
140
+
141
+ # Compute step t
142
+ x_contribution = self.x_seq[t] @ self.W_xh
143
+ if t == 0:
144
+ h_prev = self.h_0
145
+ h_prev_label = 'h_{-1}'
146
+ h_prev_desc = " (initial state)"
147
+ else:
148
+ h_prev = self.h_states[t-1]
149
+ h_prev_label = f'h_{{{t-1}}}'
150
+ h_prev_desc = ""
151
+
152
+ h_contribution = h_prev @ self.W_hh
153
+ pre_activation = x_contribution + h_contribution + self.b_h
154
+ h_result = np.tanh(pre_activation)
155
+
156
+ explanation.add_element(
157
+ ContentAST.Paragraph([
158
+ "Input contribution: ",
159
+ ContentAST.Equation(f'x_{t} W_{{xh}}', inline=True),
160
+ f" = {format_array(x_contribution)}"
161
+ ])
162
+ )
163
+
164
+ explanation.add_element(
165
+ ContentAST.Paragraph([
166
+ "Hidden contribution: ",
167
+ ContentAST.Equation(f'{h_prev_label} W_{{hh}}', inline=True),
168
+ f"{h_prev_desc} = {format_array(h_contribution)}"
169
+ ])
170
+ )
171
+
172
+ explanation.add_element(
173
+ ContentAST.Paragraph([
174
+ f"Pre-activation: {format_array(pre_activation)}"
175
+ ])
176
+ )
177
+
178
+ explanation.add_element(
179
+ ContentAST.Paragraph([
180
+ "After tanh: ",
181
+ ContentAST.Equation(f'h_{t}', inline=True),
182
+ f" = {format_array(h_result)}"
183
+ ])
184
+ )
185
+
186
+ # Add visual separator between timesteps (except after the last one)
187
+ if t < num_examples - 1:
188
+ explanation.add_element(ContentAST.Paragraph([""]))
189
+
190
+ # Show complete output sequence (rounded)
191
+ explanation.add_element(
192
+ ContentAST.Paragraph([
193
+ "Complete hidden state sequence (each row is one timestep):"
194
+ ])
195
+ )
196
+
197
+ explanation.add_element(
198
+ ContentAST.Matrix(np.round(self.h_states, digits))
199
+ )
200
+
201
+ return explanation
202
+