Permalink
Cannot retrieve contributors at this time
""" | |
Functions for aggregating and analyzing exam-related data, such as calculating | |
student exam performance. | |
""" | |
import csv | |
import os | |
import pandas as pd | |
def grade_scantron( | |
input_scantron, correct_answers, drops=[], item_value=1, incorrect_threshold=0.5 | |
): | |
""" | |
Calculate student grades from scantron data. | |
Compiles data collected from a scantron machine (5-option multiple choice | |
exam) and calculates grades for each student. Also provides descriptive | |
statistics of exam performance, as well as a list of the questions "most" | |
students got incorrect, and saves the distribution of answers for those | |
poorly performing questions. | |
This function receives 1 scantron text file and produces 2 output files. | |
Splitting of the scantron data is specific to each scantron machine. The | |
indices used in this function are correct for the scantron machine in the | |
UBC Psychology department as of 2015. Indices need to be adjusted for | |
different machines. | |
Scantron exams can be finicky. Students who incorrectly fill out scantrons | |
need to be considered. Make sure to manually inspect the text file output | |
by the scantron machine for missing answers before running this. This | |
function does not correct for human error when filling out the scantron. | |
Parameters | |
---------- | |
input_scantron : string | |
Path to the .txt file produced by the scantron machine. | |
correct_answers : list | |
A list of strings containing the *correct* exam answers. For example: | |
["A", "E", "D", "A", B"]. The order must match the order of | |
presentation on the exam (i.e. the first list item must correspond | |
to the first exam question) | |
drops : list, optional | |
List of integers containing question numbers that should be excluded | |
from calculation of grades. For example: [1, 5] will not include | |
questions 1 and 5 when calculating exam scores. | |
item_value : int, optional | |
Integer representing how many points each exam question is worth. | |
incorrect_threshold : float between [0., 1.], optional | |
Poorly performing questions are those where few students got the | |
correct answer. This parameter sets the threshold at which an item is | |
considered poor. For example, a threshold of 0.4 means that a poor | |
item is considered to be one where less than 40% of students | |
chose the correct answer. | |
""" | |
# Start and end locations of various pieces of information in the scantron text file. | |
# These need to be adjusted for different scantron machine. | |
# Currently set for the machine used in UBC Psychology | |
surname_idx = (0, 12) | |
first_name_idx = (12, 21) | |
student_num_idx = (21, 30) | |
answers_idx = 30 | |
# output directory | |
directory, filename = os.path.split(input_scantron) | |
filename = os.path.splitext(filename)[0] | |
# calculate total number of points available on the exam | |
total_points = (len(correct_answers) * item_value) - len(drops) | |
# create a pandas dataframe to hold the scantron data | |
summary = ["surname", "first_name", "student_number", "points", "percent"] | |
questions = ["Q-{}".format(i + 1) for i in range(len(correct_answers))] | |
df = pd.DataFrame(columns=summary + questions) | |
# calculate grades | |
with open(input_scantron, "r") as f: | |
scantron_data = csv.reader(f) | |
# loop through every row (student) in the input scantron file | |
for row in scantron_data: | |
surname = row[0][surname_idx[0] : surname_idx[1]].lstrip().rstrip() | |
first_name = row[0][first_name_idx[0] : first_name_idx[1]].lstrip().rstrip() | |
student_num = ( | |
row[0][student_num_idx[0] : student_num_idx[1]].lstrip().rstrip() | |
) | |
answers = row[0][answers_idx : (answers_idx + len(correct_answers))] | |
points = 0 | |
for i, pair in enumerate(zip(answers, correct_answers)): | |
if i + 1 not in drops: | |
if pair[0] == pair[1]: | |
points += item_value | |
df_summary = { | |
"surname": surname, | |
"first_name": first_name, | |
"student_number": student_num, | |
"points": points, | |
"percent": (points / total_points) * 100, | |
} | |
df_questions = {"Q-{}".format(i + 1): a for i, a in enumerate(answers)} | |
df = df.append([{**df_summary, **df_questions}], ignore_index=True) | |
df.to_excel( | |
os.path.join(directory, "{}.xls".format(filename + "_grades")), | |
sheet_name="grades", | |
index=False, | |
) | |
# write summary statistics | |
with open( | |
os.path.join(directory, "{}.txt".format(filename + "_summary")), "w" | |
) as f: | |
# calculate descriptive statistics | |
N = df.shape[0] | |
mean_percent = df["percent"].mean() | |
mean_points = df["points"].mean() | |
std_points = df["points"].std() | |
range_points = (df["points"].min(), df["points"].max()) | |
f.writelines( | |
[ | |
"Descriptive Statistics: \n\n", | |
"N: {}\n".format(N), | |
"Mean %: {:.2f}%\n".format(mean_percent), | |
"Mean score (out of {} points): {:.2f}\n".format( | |
total_points, mean_points | |
), | |
"Score SD: {:.2f}\n".format(std_points), | |
"Range: {} (Min: {}, Max: {})\n\n\n".format( | |
range_points[1] - range_points[0], range_points[0], range_points[1] | |
), | |
] | |
) | |
if len(drops) > 0: | |
f.writelines( | |
["Dropped questions: {}\n\n\n".format(", ".join(map(str, drops)))] | |
) | |
f.writelines( | |
[ | |
"Problem Items (questions that less " | |
"than {}% of students got correct):\n\n".format( | |
incorrect_threshold * 100 | |
) | |
] | |
) | |
problems = False | |
for i, item in enumerate(questions): | |
cur_q = df[item] | |
if len(cur_q[cur_q == correct_answers[i]]) < (N * incorrect_threshold): | |
problems = True | |
distribution = cur_q.value_counts() | |
f.writelines( | |
[ | |
"{} (A: {}, B: {}, C: {}, D: {}, E: {})\n".format( | |
cur_q.name, | |
distribution.A, | |
distribution.B, | |
distribution.C, | |
distribution.D, | |
distribution.E, | |
) | |
] | |
) | |
if not problems: | |
f.writelines(["None"]) |