Files
edx-platform/djangoapps/courseware/grades.py

333 lines
16 KiB
Python

import courseware.content_parser as content_parser
import courseware.modules
import logging
import random
import urllib
from collections import namedtuple
from django.conf import settings
from lxml import etree
from models import StudentModule
from student.models import UserProfile
log = logging.getLogger("mitx.courseware")
Score = namedtuple("Score", "earned possible weight graded section")
SectionPercentage = namedtuple("SectionPercentage", "percentage label summary")
class CourseGrader(object):
def grade(self, grade_sheet):
raise NotImplementedError
class WeightedSubsectionsGrader(CourseGrader):
"""
This grader takes a list of tuples containing (grader, category_name, weight) and computes
a final grade by totalling the contribution of each sub grader and weighting it
accordingly. For example, the sections may be
[ (homeworkGrader, "Homework", 0.15), (labGrader, "Labs", 0.15), (midtermGrader, "Midterm", 0.30), (finalGrader, "Final", 0.40) ]
All items in section_breakdown for each subgrader will be combined. A grade_breakdown will be
composed using the score from each grader.
"""
def __init__(self, sections):
self.sections = sections
def grade(self, grade_sheet):
total_percent = 0.0
section_breakdown = []
grade_breakdown = []
for subgrader, section_name, weight in self.sections:
subgrade_result = subgrader.grade(grade_sheet)
weightedPercent = subgrade_result['percent'] * weight
section_detail = "{0} = {1:.1%} of a possible {2:.0%}".format(section_name, weightedPercent, weight)
total_percent += weightedPercent
section_breakdown += subgrade_result['section_breakdown']
grade_breakdown.append( {'percent' : weightedPercent, 'detail' : section_detail, 'category' : section_name} )
return {'percent' : total_percent,
'section_breakdown' : section_breakdown,
'grade_breakdown' : grade_breakdown}
class SingleSectionGrader(CourseGrader):
"""
This grades a single section with the format section_format and the name section_name.
If the section_name is not appropriate for the short short_label or category, they each may
be specified individually.
"""
def __init__(self, section_format, section_name, short_label = None, category = None):
self.section_format = section_format
self.section_name = section_name
self.short_label = short_label or section_name
self.category = category or section_name
def grade(self, grade_sheet):
foundScore = None
if self.section_format in grade_sheet:
for score in grade_sheet[self.section_format]:
if score.section == self.section_name:
foundScore = score
break
if foundScore:
percent = foundScore.earned / float(foundScore.possible)
detail = "{name} - {percent:.0%} ({earned:g}/{possible:g})".format( name = self.section_name,
percent = percent,
earned = foundScore.earned,
possible = foundScore.possible)
else:
percent = 0.0
detail = "{name} - 0% (?/?)".format(name = self.section_name)
breakdown = [{'percent': percent, 'label': self.short_label, 'detail': detail, 'category': self.category, 'prominent': True}]
return {'percent' : percent,
'section_breakdown' : breakdown,
#No grade_breakdown here
}
class AssignmentFormatGrader(CourseGrader):
"""
Grades all sections specified in course_format with an equal weight. A specified
number of lowest scores can be dropped from the calculation. The minimum number of
sections in this format must be specified (even if those sections haven't been
written yet).
category should be presentable to the user, but may not appear. When the grade breakdown is
displayed, scores from the same category will be similar (for example, by color).
section_type is a string that is the type of a singular section. For example, for Labs it
would be "Lab". This defaults to be the same as category.
short_label is similar to section_type, but shorter. For example, for Homework it would be
"HW".
"""
def __init__(self, course_format, min_number, drop_count, category = None, section_type = None, short_label = None):
self.course_format = course_format
self.min_number = min_number
self.drop_count = drop_count
self.category = category or course_format
self.section_type = section_type or course_format
self.short_label = short_label or section_type
def grade(self, grade_sheet):
def totalWithDrops(breakdown, drop_count):
#create an array of tuples with (index, mark), sorted by mark['percent'] descending
sorted_breakdown = sorted( enumerate(breakdown), key=lambda x: -x[1]['percent'] )
# A list of the indices of the dropped scores
dropped_indices = [x[0] for x in sorted_breakdown[-drop_count:]]
aggregate_score = 0
for index, mark in enumerate(breakdown):
if index not in dropped_indices:
aggregate_score += mark['percent']
aggregate_score /= len(scores) - drop_count
return aggregate_score, dropped_indices
#Figure the homework scores
scores = grade_sheet.get(self.course_format, [])
breakdown = []
for i in range(12):
if i < len(scores):
percentage = scores[i].earned / float(scores[i].possible)
summary = "{section_type} {index} - {name} - {percent:.0%} ({earned:g}/{possible:g})".format(index = i+1,
section_type = self.section_type,
name = scores[i].section,
percent = percentage,
earned = scores[i].earned,
possible = scores[i].possible )
else:
percentage = 0
summary = "{section_type} {index} Unreleased - 0% (?/?)".format(index = i+1, section_type = self.section_type)
if settings.GENERATE_PROFILE_SCORES:
points_possible = random.randrange(10, 50)
points_earned = random.randrange(5, points_possible)
percentage = points_earned / float(points_possible)
summary = "{section_type} {index} - {name} - {percent:.0%} ({earned:g}/{possible:g})".format(index = i+1,
section_type = self.section_type,
name = "Randomly Generated",
percent = percentage,
earned = points_earned,
possible = points_possible )
short_label = "{short_label} {index:02d}".format(index = i+1, short_label = self.short_label)
breakdown.append( {'percent': percentage, 'label': short_label, 'detail': summary, 'category': self.category} )
total_percent, dropped_indices = totalWithDrops(breakdown, self.drop_count)
for dropped_index in dropped_indices:
breakdown[dropped_index]['mark'] = {'detail': "The lowest {drop_count} {section_type} scores are dropped.".format(drop_count = self.drop_count, section_type=self.section_type) }
total_detail = "{section_type} Average = {percent:.0%}".format(percent = total_percent, section_type = self.section_type)
total_label = "{short_label} Avg".format(short_label = self.short_label)
breakdown.append( {'percent': total_percent, 'label': total_label, 'detail': total_detail, 'category': self.category, 'prominent': True} )
return {'percent' : total_percent,
'section_breakdown' : breakdown,
#No grade_breakdown here
}
def get_score(user, problem, cache):
## HACK: assumes max score is fixed per problem
id = problem.get('id')
correct = 0
# If the ID is not in the cache, add the item
if id not in cache:
module = StudentModule(module_type = 'problem', # TODO: Move into StudentModule.__init__?
module_id = id,
student = user,
state = None,
grade = 0,
max_grade = None,
done = 'i')
cache[id] = module
# Grab the # correct from cache
if id in cache:
response = cache[id]
if response.grade!=None:
correct=response.grade
# Grab max grade from cache, or if it doesn't exist, compute and save to DB
if id in cache and response.max_grade != None:
total = response.max_grade
else:
total=courseware.modules.capa_module.Module(etree.tostring(problem), "id").max_score()
response.max_grade = total
response.save()
return (correct, total)
def grade_sheet(student):
"""
This pulls a summary of all problems in the course. It returns a dictionary with two datastructures:
- courseware_summary is a summary of all sections with problems in the course. It is organized as an array of chapters,
each containing an array of sections, each containing an array of scores. This contains information for graded and ungraded
problems, and is good for displaying a course summary with due dates, etc.
- grade_summary is a summary of how the final grade breaks down. It is an array of "sections". Each section can either be
a conglomerate of scores (like labs or homeworks) which has subscores and a totalscore, or a section can be all from one assignment
(such as a midterm or final) and only has a totalscore. Each section has a weight that shows how it contributes to the total grade.
"""
dom=content_parser.course_file(student)
course = dom.xpath('//course/@name')[0]
xmlChapters = dom.xpath('//course[@name=$course]/chapter', course=course)
responses=StudentModule.objects.filter(student=student)
response_by_id = {}
for response in responses:
response_by_id[response.module_id] = response
totaled_scores = {}
chapters=[]
for c in xmlChapters:
sections = []
chname=c.get('name')
for s in dom.xpath('//course[@name=$course]/chapter[@name=$chname]/section',
course=course, chname=chname):
problems=dom.xpath('//course[@name=$course]/chapter[@name=$chname]/section[@name=$section]//problem',
course=course, chname=chname, section=s.get('name'))
graded = True if s.get('graded') == "true" else False
scores=[]
if len(problems)>0:
for p in problems:
(correct,total) = get_score(student, p, response_by_id)
# id = p.get('id')
# correct = 0
# if id in response_by_id:
# response = response_by_id[id]
# if response.grade!=None:
# correct=response.grade
# total=courseware.modules.capa_module.Module(etree.tostring(p), "id").max_score() # TODO: Add state. Not useful now, but maybe someday problems will have randomized max scores?
# print correct, total
if settings.GENERATE_PROFILE_SCORES:
if total > 1:
correct = random.randrange( max(total-2, 1) , total + 1 )
else:
correct = total
scores.append( Score(int(correct),total, float(p.get("weight", 1)), graded, p.get("name")) )
section_total, graded_total = aggregate_scores(scores, s)
#Add the graded total to totaled_scores
format = s.get('format') if s.get('format') else ""
subtitle = s.get('subtitle') if s.get('subtitle') else format
if format and graded_total[1] > 0:
format_scores = totaled_scores.get(format, [])
format_scores.append( graded_total )
totaled_scores[ format ] = format_scores
section_score={'section':s.get("name"),
'scores':scores,
'section_total' : section_total,
'format' : format,
'subtitle' : subtitle,
'due' : s.get("due") or "",
'graded' : graded,
}
sections.append(section_score)
chapters.append({'course':course,
'chapter' : c.get("name"),
'sections' : sections,})
#TODO: This grader declaration should live in the data repository. It is only here now to get it working
hwGrader = AssignmentFormatGrader("Homework", 12, 2, "Homework", "Homework", "HW")
labGrader = AssignmentFormatGrader("Lab", 12, 2, "Labs", "Lab", "Lab")
midtermGrader = SingleSectionGrader("Examination", "Midterm Exam", "Midterm")
finalGrader = SingleSectionGrader("Examination", "Final Exam", "Final")
grader = WeightedSubsectionsGrader( [(hwGrader, hwGrader.category, 0.15), (labGrader, labGrader.category, 0.15),
(midtermGrader, midtermGrader.category, 0.30), (finalGrader, finalGrader.category, 0.40)] )
grade_summary = grader.grade(totaled_scores)
return {'courseware_summary' : chapters,
'grade_summary' : grade_summary}
def aggregate_scores(scores, section):
#TODO: What does a possible score of zero mean? We need to think what extra credit is
scores = filter( lambda score: score.possible > 0, scores )
total_correct_graded = sum((score.earned*1.0/score.possible)*score.weight for score in scores if score.graded)
total_possible_graded = sum(score.weight for score in scores if score.graded)
total_correct = sum((score.earned*1.0/score.possible)*score.weight for score in scores)
total_possible = sum(score.weight for score in scores)
section_weight = section.get("weight", 1)
#regardless of whether or not it is graded
all_total = Score(total_correct,
total_possible,
section_weight,
False,
section.get("name"))
#selecting only graded things
graded_total = Score(total_correct_graded,
total_possible_graded,
section_weight,
True,
section.get("name"))
return all_total, graded_total