pexams 0.1.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.
- pexams/__init__.py +0 -0
- pexams/analysis.py +260 -0
- pexams/correct_exams.py +570 -0
- pexams/generate_exams.py +560 -0
- pexams/layout.py +175 -0
- pexams/main.py +190 -0
- pexams/schemas.py +38 -0
- pexams/translations.py +103 -0
- pexams/utils.py +4 -0
- pexams-0.1.0.dist-info/METADATA +246 -0
- pexams-0.1.0.dist-info/RECORD +15 -0
- pexams-0.1.0.dist-info/WHEEL +5 -0
- pexams-0.1.0.dist-info/entry_points.txt +2 -0
- pexams-0.1.0.dist-info/licenses/LICENSE +21 -0
- pexams-0.1.0.dist-info/top_level.txt +1 -0
pexams/__init__.py
ADDED
|
File without changes
|
pexams/analysis.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
import matplotlib.pyplot as plt
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import numpy as np
|
|
6
|
+
from collections import Counter
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Optional, List
|
|
9
|
+
from tabulate import tabulate
|
|
10
|
+
from matplotlib.patches import Patch
|
|
11
|
+
|
|
12
|
+
def _plot_answer_distribution(df, solutions_per_model, output_dir):
|
|
13
|
+
"""
|
|
14
|
+
Plots the distribution of answers for each question in a single grouped bar chart,
|
|
15
|
+
normalized to model 1's answer order.
|
|
16
|
+
"""
|
|
17
|
+
# Assuming the first model key is the reference (e.g., "1")
|
|
18
|
+
ref_model_key = sorted(solutions_per_model.keys())[0]
|
|
19
|
+
ref_solutions = solutions_per_model[ref_model_key]
|
|
20
|
+
|
|
21
|
+
# Create a mapping from option text to the reference index for each question
|
|
22
|
+
option_text_to_ref_idx = {}
|
|
23
|
+
for q_id, q_data in ref_solutions.items():
|
|
24
|
+
option_text_to_ref_idx[q_id] = {opt['text']: i for i, opt in enumerate(q_data['options'])}
|
|
25
|
+
|
|
26
|
+
# Translate all student answers to the reference model's option indexing
|
|
27
|
+
all_answers_translated = []
|
|
28
|
+
for _, row in df.iterrows():
|
|
29
|
+
model_id = str(row['model_id'])
|
|
30
|
+
if model_id not in solutions_per_model:
|
|
31
|
+
continue
|
|
32
|
+
|
|
33
|
+
current_model_solutions = solutions_per_model[model_id]
|
|
34
|
+
|
|
35
|
+
for q_num_str, ans_char in row.items():
|
|
36
|
+
if not q_num_str.startswith('answer_'):
|
|
37
|
+
continue
|
|
38
|
+
|
|
39
|
+
q_id = int(q_num_str.split('_')[1])
|
|
40
|
+
if q_id not in current_model_solutions or not isinstance(ans_char, str) or ans_char == 'NA':
|
|
41
|
+
continue
|
|
42
|
+
|
|
43
|
+
# Convert character answer to index (A=0, B=1, ...)
|
|
44
|
+
ans_idx = ord(ans_char) - ord('A')
|
|
45
|
+
|
|
46
|
+
# Get the text of the option the student chose
|
|
47
|
+
try:
|
|
48
|
+
chosen_option_text = current_model_solutions[q_id]['options'][ans_idx]['text']
|
|
49
|
+
except IndexError:
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
# Find the corresponding index in the reference model
|
|
53
|
+
if q_id in option_text_to_ref_idx and chosen_option_text in option_text_to_ref_idx[q_id]:
|
|
54
|
+
ref_idx = option_text_to_ref_idx[q_id][chosen_option_text]
|
|
55
|
+
all_answers_translated.append({'question_id': q_id, 'ref_answer_idx': ref_idx})
|
|
56
|
+
|
|
57
|
+
if not all_answers_translated:
|
|
58
|
+
logging.warning("Could not generate answer distribution plot: No valid translated answers found.")
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
translated_df = pd.DataFrame(all_answers_translated)
|
|
62
|
+
|
|
63
|
+
question_ids = sorted(ref_solutions.keys())
|
|
64
|
+
num_questions = len(question_ids)
|
|
65
|
+
|
|
66
|
+
max_num_options = 0
|
|
67
|
+
if ref_solutions:
|
|
68
|
+
max_num_options = max(len(q_data['options']) for q_data in ref_solutions.values())
|
|
69
|
+
|
|
70
|
+
answer_counts_by_q = {
|
|
71
|
+
q_id: translated_df[translated_df['question_id'] == q_id]['ref_answer_idx'].value_counts()
|
|
72
|
+
for q_id in question_ids
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
fig, ax = plt.subplots(figsize=(max(15, num_questions * 2), 8))
|
|
76
|
+
x = np.arange(num_questions)
|
|
77
|
+
width = 0.8 / max_num_options if max_num_options > 0 else 0.8
|
|
78
|
+
|
|
79
|
+
for i in range(max_num_options):
|
|
80
|
+
counts = [answer_counts_by_q[q_id].get(i, 0) for q_id in question_ids]
|
|
81
|
+
offset = (i - (max_num_options - 1) / 2) * width
|
|
82
|
+
|
|
83
|
+
colors = []
|
|
84
|
+
for q_id in question_ids:
|
|
85
|
+
correct_idx = ref_solutions[q_id]['correct_answer_index']
|
|
86
|
+
# Only add a bar if this option index is valid for the question
|
|
87
|
+
if i < len(ref_solutions[q_id]['options']):
|
|
88
|
+
colors.append('green' if i == correct_idx else 'red')
|
|
89
|
+
else:
|
|
90
|
+
# This is a placeholder, this bar won't be plotted
|
|
91
|
+
colors.append('none')
|
|
92
|
+
|
|
93
|
+
# Filter positions, counts, and colors for valid options
|
|
94
|
+
valid_positions = [x[j] + offset for j, q_id in enumerate(question_ids) if i < len(ref_solutions[q_id]['options'])]
|
|
95
|
+
valid_counts = [counts[j] for j, q_id in enumerate(question_ids) if i < len(ref_solutions[q_id]['options'])]
|
|
96
|
+
valid_colors = [c for c in colors if c != 'none']
|
|
97
|
+
|
|
98
|
+
if valid_positions:
|
|
99
|
+
ax.bar(valid_positions, valid_counts, width, label=f'Option {chr(ord("A") + i)}', color=valid_colors)
|
|
100
|
+
|
|
101
|
+
ax.set_title('Answer Distribution per Question', fontsize=16)
|
|
102
|
+
ax.set_xlabel('Question ID', fontsize=12)
|
|
103
|
+
ax.set_ylabel('Number of Students', fontsize=12)
|
|
104
|
+
ax.set_xticks(x)
|
|
105
|
+
ax.set_xticklabels([f'Q{q_id}' for q_id in question_ids])
|
|
106
|
+
|
|
107
|
+
max_count = 0
|
|
108
|
+
if all_answers_translated:
|
|
109
|
+
max_count = translated_df.groupby('question_id')['ref_answer_idx'].count().max()
|
|
110
|
+
|
|
111
|
+
ax.set_yticks(np.arange(0, max_count + 2, 1))
|
|
112
|
+
|
|
113
|
+
# Custom legend for colors
|
|
114
|
+
legend_elements = [Patch(facecolor='green', label='Correct Answer'),
|
|
115
|
+
Patch(facecolor='red', label='Incorrect Answer')]
|
|
116
|
+
ax.legend(handles=legend_elements)
|
|
117
|
+
|
|
118
|
+
plt.tight_layout()
|
|
119
|
+
plot_filename = os.path.join(output_dir, "answer_distribution.png")
|
|
120
|
+
try:
|
|
121
|
+
plt.savefig(plot_filename)
|
|
122
|
+
logging.info(f"Answer distribution plot saved to {os.path.abspath(plot_filename)}")
|
|
123
|
+
except Exception as e:
|
|
124
|
+
logging.error(f"Error saving answer distribution plot: {e}")
|
|
125
|
+
|
|
126
|
+
def parse_q_list(q_str: Optional[str]) -> List[int]:
|
|
127
|
+
"""Converts a comma-separated string of question numbers to a sorted list of unique integers."""
|
|
128
|
+
if not q_str:
|
|
129
|
+
return []
|
|
130
|
+
try:
|
|
131
|
+
return sorted(list(set(int(q.strip()) for q in q_str.split(',') if q.strip().isdigit())))
|
|
132
|
+
except ValueError:
|
|
133
|
+
logging.warning(f"Invalid format for question list string: '{q_str}'. Expected comma-separated numbers. Returning empty list.")
|
|
134
|
+
return []
|
|
135
|
+
|
|
136
|
+
def analyze_results(csv_filepath, max_score, output_dir=".", void_questions_str: Optional[str] = None, void_questions_nicely_str: Optional[str] = None, solutions_per_model=None):
|
|
137
|
+
"""
|
|
138
|
+
Analyzes exam results from a CSV file, scales scores to 0-10,
|
|
139
|
+
plots score distribution, and shows statistics.
|
|
140
|
+
Allows for voiding questions or voiding them 'nicely' (only if incorrect/unanswered).
|
|
141
|
+
"""
|
|
142
|
+
if not os.path.exists(csv_filepath):
|
|
143
|
+
logging.error(f"Error: CSV file not found at {csv_filepath}")
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
df = pd.read_csv(csv_filepath)
|
|
148
|
+
logging.info(f"Successfully loaded {csv_filepath}")
|
|
149
|
+
except Exception as e:
|
|
150
|
+
logging.error(f"Error reading CSV file {csv_filepath}: {e}")
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
if 'score' not in df.columns:
|
|
154
|
+
logging.error(f"Error: 'score' column not found in {csv_filepath}.")
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
df['score_numeric'] = pd.to_numeric(df['score'], errors='coerce')
|
|
158
|
+
|
|
159
|
+
original_rows = len(df)
|
|
160
|
+
df.dropna(subset=['score_numeric'], inplace=True)
|
|
161
|
+
if len(df) < original_rows:
|
|
162
|
+
logging.warning(f"Dropped {original_rows - len(df)} rows due to non-numeric 'score' values.")
|
|
163
|
+
|
|
164
|
+
if df.empty:
|
|
165
|
+
logging.error("No valid numeric data in 'score' column after cleaning.")
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
# For pexams, the score is already the count of correct answers.
|
|
169
|
+
# We need to know the penalty for incorrect answers to adjust for voiding.
|
|
170
|
+
# Assuming a penalty of -1/3 for now, as it's a common case.
|
|
171
|
+
# This part is more complex than in rexams because we don't have per-question points.
|
|
172
|
+
# A simplification: for voided questions, we assume they give 1 point if correct.
|
|
173
|
+
# We don't have information about incorrect answers to add back penalties.
|
|
174
|
+
# This is a limitation of the current pexams CSV format.
|
|
175
|
+
# Let's proceed with a simplified voiding logic.
|
|
176
|
+
|
|
177
|
+
logging.warning("Simplified 'void' logic is being used. It assumes each question is worth 1 point and does not handle negative marking for voiding.")
|
|
178
|
+
|
|
179
|
+
void_q_list = parse_q_list(void_questions_str)
|
|
180
|
+
|
|
181
|
+
# We can't implement 'void_nicely' without per-question results in the CSV.
|
|
182
|
+
if void_questions_nicely_str:
|
|
183
|
+
logging.warning("'void_nicely' is not supported with the current CSV format from pexams. Ignoring.")
|
|
184
|
+
|
|
185
|
+
if solutions_per_model:
|
|
186
|
+
_plot_answer_distribution(df, solutions_per_model, output_dir)
|
|
187
|
+
|
|
188
|
+
adjustments_made = bool(void_q_list)
|
|
189
|
+
|
|
190
|
+
df['score_adjusted'] = df['score_numeric'].copy()
|
|
191
|
+
max_score_adjusted = float(max_score)
|
|
192
|
+
|
|
193
|
+
if adjustments_made:
|
|
194
|
+
logging.info(f"Voiding questions: {void_q_list}. Max score will be reduced.")
|
|
195
|
+
# We can't adjust student scores without knowing which they got right.
|
|
196
|
+
# The best we can do is adjust the max score.
|
|
197
|
+
max_score_adjusted -= len(void_q_list)
|
|
198
|
+
logging.info(f"Adjusted max score is now: {max_score_adjusted}")
|
|
199
|
+
|
|
200
|
+
df['mark'] = (df['score_adjusted'] / max_score_adjusted) * 10 if max_score_adjusted > 0 else 0
|
|
201
|
+
df['mark_clipped'] = np.clip(df['mark'], 0, 10)
|
|
202
|
+
|
|
203
|
+
print("\n--- Descriptive Statistics for Marks (0-10 scale) ---")
|
|
204
|
+
stats = df['mark_clipped'].describe()
|
|
205
|
+
print(stats)
|
|
206
|
+
|
|
207
|
+
# --- Plotting ---
|
|
208
|
+
plt.style.use('seaborn-v0_8-whitegrid')
|
|
209
|
+
fig, ax = plt.subplots(figsize=(12, 7))
|
|
210
|
+
|
|
211
|
+
df['mark_binned_for_plot'] = np.floor(df['mark_clipped'].fillna(0) + 0.5).astype(int)
|
|
212
|
+
score_counts = Counter(df['mark_binned_for_plot'])
|
|
213
|
+
all_possible_scores = np.arange(0, 11)
|
|
214
|
+
frequencies = [score_counts.get(s, 0) for s in all_possible_scores]
|
|
215
|
+
|
|
216
|
+
plt.bar(all_possible_scores, frequencies, width=1.0, edgecolor='black', align='center', color='skyblue')
|
|
217
|
+
|
|
218
|
+
ax.set_title(f'Distribution of Exam Marks (Scaled to 0-10 from Max Raw: {max_score_adjusted})', fontsize=15)
|
|
219
|
+
ax.set_xlabel('Mark (0-10 Scale)', fontsize=12)
|
|
220
|
+
ax.set_ylabel('Number of Students', fontsize=12)
|
|
221
|
+
ax.set_xticks(np.arange(0, 11, 1))
|
|
222
|
+
ax.set_xlim(-0.5, 10.5)
|
|
223
|
+
|
|
224
|
+
if max(frequencies, default=0) > 0:
|
|
225
|
+
ax.set_ylim(top=max(frequencies) * 1.1)
|
|
226
|
+
else:
|
|
227
|
+
ax.set_ylim(top=1)
|
|
228
|
+
|
|
229
|
+
ax.grid(axis='y', linestyle='--', alpha=0.7)
|
|
230
|
+
|
|
231
|
+
mean_mark = df['mark_clipped'].mean()
|
|
232
|
+
median_mark = df['mark_clipped'].median()
|
|
233
|
+
ax.axvline(mean_mark, color='red', linestyle='dashed', linewidth=1.5, label=f'Mean: {mean_mark:.2f}')
|
|
234
|
+
ax.axvline(median_mark, color='green', linestyle='dashed', linewidth=1.5, label=f'Median: {median_mark:.2f}')
|
|
235
|
+
ax.legend()
|
|
236
|
+
|
|
237
|
+
if not os.path.exists(output_dir):
|
|
238
|
+
os.makedirs(output_dir)
|
|
239
|
+
logging.info(f"Created output directory: {output_dir}")
|
|
240
|
+
|
|
241
|
+
plot_filename = os.path.join(output_dir, "mark_distribution_0_10.png")
|
|
242
|
+
try:
|
|
243
|
+
plt.savefig(plot_filename)
|
|
244
|
+
logging.info(f"\nPlot saved to {os.path.abspath(plot_filename)}")
|
|
245
|
+
except Exception as e:
|
|
246
|
+
logging.error(f"Error saving plot: {e}")
|
|
247
|
+
|
|
248
|
+
# --- Print Student Marks ---
|
|
249
|
+
print("\n--- Student Marks (0-10 Scale) ---")
|
|
250
|
+
|
|
251
|
+
results_to_print_df = df[['student_id', 'student_name', 'mark_clipped']].copy()
|
|
252
|
+
results_to_print_df.rename(columns={'mark_clipped': 'mark'}, inplace=True)
|
|
253
|
+
|
|
254
|
+
# Save to a new CSV
|
|
255
|
+
final_csv_path = os.path.join(output_dir, "final_marks.csv")
|
|
256
|
+
results_to_print_df.to_csv(final_csv_path, index=False)
|
|
257
|
+
logging.info(f"Final marks saved to {os.path.abspath(final_csv_path)}")
|
|
258
|
+
|
|
259
|
+
# Print to console
|
|
260
|
+
print(tabulate(results_to_print_df, headers='keys', tablefmt='psql', floatfmt=".2f"))
|