Source code for pyutils_sh.exam

"""
Functions for aggregating and analyzing exam-related data, such as calculating 
student exam performance.
"""

import csv
import os
import pandas as pd


[docs]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"])