Merge pull request #14032 from edx/beryl/unattempted-grade-report

Update grade report to distinguish between Not Attempted and 0
This commit is contained in:
Nimisha Asthagiri
2016-11-30 18:03:07 -05:00
committed by GitHub
37 changed files with 694 additions and 700 deletions

View File

@@ -404,7 +404,7 @@ def _cert_info(user, course_overview, cert_status, course_mode): # pylint: disa
)
if status in {'generating', 'ready', 'notpassing', 'restricted', 'auditing', 'unverified'}:
persisted_grade = CourseGradeFactory(user).get_persisted(course_overview)
persisted_grade = CourseGradeFactory().get_persisted(user, course_overview)
if persisted_grade is not None:
status_dict['grade'] = unicode(persisted_grade.percent)
elif 'grade' in cert_status:

View File

@@ -10,7 +10,7 @@ class CorrectMap(object):
in a capa problem. The response evaluation result for each answer_id includes
(correctness, npoints, msg, hint, hintmode).
- correctness : 'correct', 'incorrect', or 'partially-correct'
- correctness : 'correct', 'incorrect', 'partially-correct', or 'incomplete'
- npoints : None, or integer specifying number of points awarded for this answer_id
- msg : string (may have HTML) giving extra message response
(displayed below textline or textbox)

View File

@@ -5,6 +5,7 @@ Code used to calculate learner grades.
from __future__ import division
import abc
from collections import OrderedDict
import inspect
import logging
import random
@@ -18,17 +19,13 @@ class ScoreBase(object):
"""
Abstract base class for encapsulating fields of values scores.
Field common to all scores include:
display_name (string) - the display name of the module
module_id (UsageKey) - the location of the module
graded (boolean) - whether or not this module is graded
attempted (boolean) - whether the module was attempted
"""
__metaclass__ = abc.ABCMeta
def __init__(self, graded, display_name, module_id, attempted):
def __init__(self, graded, attempted):
self.graded = graded
self.display_name = display_name
self.module_id = module_id
self.attempted = attempted
def __eq__(self, other):
@@ -55,10 +52,10 @@ class ProblemScore(ScoreBase):
"""
def __init__(self, raw_earned, raw_possible, weighted_earned, weighted_possible, weight, *args, **kwargs):
super(ProblemScore, self).__init__(*args, **kwargs)
self.raw_earned = raw_earned
self.raw_possible = raw_possible
self.earned = weighted_earned
self.possible = weighted_possible
self.raw_earned = float(raw_earned) if raw_earned is not None else None
self.raw_possible = float(raw_possible) if raw_possible is not None else None
self.earned = float(weighted_earned) if weighted_earned is not None else None
self.possible = float(weighted_possible) if weighted_possible is not None else None
self.weight = weight
@@ -71,8 +68,8 @@ class AggregatedScore(ScoreBase):
"""
def __init__(self, tw_earned, tw_possible, *args, **kwargs):
super(AggregatedScore, self).__init__(*args, **kwargs)
self.earned = tw_earned
self.possible = tw_possible
self.earned = float(tw_earned) if tw_earned is not None else None
self.possible = float(tw_possible) if tw_possible is not None else None
def float_sum(iterable):
@@ -82,11 +79,9 @@ def float_sum(iterable):
return float(sum(iterable))
def aggregate_scores(scores, display_name="summary", location=None):
def aggregate_scores(scores):
"""
scores: A list of ScoreBase objects
display_name: The display name for the score object
location: The location under which all objects in scores are located
returns: A tuple (all_total, graded_total).
all_total: A ScoreBase representing the total score summed over all input scores
graded_total: A ScoreBase representing the score summed over all graded input scores
@@ -100,11 +95,11 @@ def aggregate_scores(scores, display_name="summary", location=None):
any_attempted = any(score.attempted for score in scores)
# regardless of whether it is graded
all_total = AggregatedScore(total_correct, total_possible, False, display_name, location, any_attempted)
all_total = AggregatedScore(total_correct, total_possible, False, any_attempted)
# selecting only graded things
graded_total = AggregatedScore(
total_correct_graded, total_possible_graded, True, display_name, location, any_attempted_graded,
total_correct_graded, total_possible_graded, True, any_attempted_graded,
)
return all_total, graded_total
@@ -126,9 +121,8 @@ def grader_from_conf(conf):
This creates a CourseGrader from a configuration (such as in course_settings.py).
The conf can simply be an instance of CourseGrader, in which case no work is done.
More commonly, the conf is a list of dictionaries. A WeightedSubsectionsGrader
with AssignmentFormatGrader's or SingleSectionGrader's as subsections will be
generated. Every dictionary should contain the parameters for making either a
AssignmentFormatGrader or SingleSectionGrader, in addition to a 'weight' key.
with AssignmentFormatGraders will be generated. Every dictionary should contain
the parameters for making an AssignmentFormatGrader, in addition to a 'weight' key.
"""
if isinstance(conf, CourseGrader):
return conf
@@ -137,27 +131,14 @@ def grader_from_conf(conf):
for subgraderconf in conf:
subgraderconf = subgraderconf.copy()
weight = subgraderconf.pop("weight", 0)
# NOTE: 'name' used to exist in SingleSectionGrader. We are deprecating SingleSectionGrader
# and converting everything into an AssignmentFormatGrader by adding 'min_count' and
# 'drop_count'. AssignmentFormatGrader does not expect 'name', so if it appears
# in bad_args, go ahead remove it (this causes no errors). Eventually, SingleSectionGrader
# should be completely removed.
name = 'name'
try:
if 'min_count' in subgraderconf:
#This is an AssignmentFormatGrader
subgrader_class = AssignmentFormatGrader
elif name in subgraderconf:
#This is an SingleSectionGrader
subgrader_class = SingleSectionGrader
else:
raise ValueError("Configuration has no appropriate grader class.")
bad_args = invalid_args(subgrader_class.__init__, subgraderconf)
# See note above concerning 'name'.
if bad_args.issuperset({name}):
bad_args = bad_args - {name}
del subgraderconf[name]
if len(bad_args) > 0:
log.warning("Invalid arguments for a subgrader: %s", bad_args)
for key in bad_args:
@@ -194,10 +175,10 @@ class CourseGrader(object):
- section_breakdown: This is a list of dictionaries which provide details on sections
that were graded. These are used for display in a graph or chart. The format for a
section_breakdown dictionary is explained below.
- grade_breakdown: This is a list of dictionaries which provide details on the contributions
of the final percentage grade. This is a higher level breakdown, for when the grade is constructed
of a few very large sections (such as Homeworks, Labs, a Midterm, and a Final). The format for
a grade_breakdown is explained below. This section is optional.
- grade_breakdown: This is a dict of dictionaries, keyed by category, which provide details on
the contributions of the final percentage grade. This is a higher level breakdown, for when the
grade is constructed of a few very large sections (such as Homeworks, Labs, a Midterm, and a Final).
The format for a grade_breakdown is explained below. This section is optional.
A dictionary in the section_breakdown list has the following keys:
percent: A float percentage for the section.
@@ -208,7 +189,7 @@ class CourseGrader(object):
prominent: A boolean value indicating that this section should be displayed as more prominent
than other items.
A dictionary in the grade_breakdown list has the following keys:
A dictionary in the grade_breakdown dict has the following keys:
percent: A float percentage in the breakdown. All percents should add up to the final percentage.
detail: A string explanation of this breakdown. E.g. "Homework - 10% of a possible 15%"
category: A string identifying the category. Items with the same category are grouped together
@@ -241,77 +222,32 @@ class WeightedSubsectionsGrader(CourseGrader):
a value > 1, the student may end up with a percent > 100%. This allows for sections that
are extra credit.
"""
def __init__(self, sections):
self.sections = sections
def __init__(self, subgraders):
self.subgraders = subgraders
def grade(self, grade_sheet, generate_random_scores=False):
total_percent = 0.0
section_breakdown = []
grade_breakdown = []
grade_breakdown = OrderedDict()
for subgrader, category, weight in self.sections:
for subgrader, assignment_type, weight in self.subgraders:
subgrade_result = subgrader.grade(grade_sheet, generate_random_scores)
weighted_percent = subgrade_result['percent'] * weight
section_detail = u"{0} = {1:.2%} of a possible {2:.2%}".format(category, weighted_percent, weight)
section_detail = u"{0} = {1:.2%} of a possible {2:.2%}".format(assignment_type, weighted_percent, weight)
total_percent += weighted_percent
section_breakdown += subgrade_result['section_breakdown']
grade_breakdown.append({'percent': weighted_percent, 'detail': section_detail, 'category': category})
return {'percent': total_percent,
'section_breakdown': section_breakdown,
'grade_breakdown': grade_breakdown}
class SingleSectionGrader(CourseGrader):
"""
This grades a single section with the format 'type' and the name 'name'.
If the name is not appropriate for the short short_label or category, they each may
be specified individually.
"""
def __init__(self, type, name, short_label=None, category=None): # pylint: disable=redefined-builtin
self.type = type
self.name = name
self.short_label = short_label or name
self.category = category or name
def grade(self, grade_sheet, generate_random_scores=False):
found_score = None
if self.type in grade_sheet:
for score in grade_sheet[self.type]:
if score.display_name == self.name:
found_score = score
break
if found_score or generate_random_scores:
if generate_random_scores: # for debugging!
earned = random.randint(2, 15)
possible = random.randint(earned, 15)
else: # We found the score
earned = found_score.earned
possible = found_score.possible
percent = earned / possible
detail = u"{name} - {percent:.0%} ({earned:.3n}/{possible:.3n})".format(
name=self.name,
percent=percent,
earned=float(earned),
possible=float(possible)
)
else:
percent = 0.0
detail = u"{name} - 0% (?/?)".format(name=self.name)
breakdown = [{'percent': percent, 'label': self.short_label,
'detail': detail, 'category': self.category, 'prominent': True}]
grade_breakdown[assignment_type] = {
'percent': weighted_percent,
'detail': section_detail,
'category': assignment_type,
}
return {
'percent': percent,
'section_breakdown': breakdown,
#No grade_breakdown here
'percent': total_percent,
'section_breakdown': section_breakdown,
'grade_breakdown': grade_breakdown
}
@@ -332,9 +268,9 @@ class AssignmentFormatGrader(CourseGrader):
hide_average is to suppress the display of the total score in this grader and instead
only show each assignment in this grader in the breakdown.
If there is only a single assignment in this grader, then it acts like a SingleSectionGrader
and returns only one entry for the grader. Since the assignment and the total are the same,
the total is returned but is not labeled as an average.
If there is only a single assignment in this grader, then it returns only one entry for the
grader. Since the assignment and the total are the same, the total is returned but is not
labeled as an average.
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).
@@ -373,9 +309,12 @@ class AssignmentFormatGrader(CourseGrader):
def grade(self, grade_sheet, generate_random_scores=False):
def total_with_drops(breakdown, drop_count):
'''calculates total score for a section while dropping lowest scores'''
#create an array of tuples with (index, mark), sorted by mark['percent'] descending
"""
Calculates total score for a section while dropping lowest scores
"""
# 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 = []
if drop_count > 0:
@@ -390,8 +329,7 @@ class AssignmentFormatGrader(CourseGrader):
return aggregate_score, dropped_indices
#Figure the homework scores
scores = grade_sheet.get(self.type, [])
scores = grade_sheet.get(self.type, {}).values()
breakdown = []
for i in range(max(self.min_count, len(scores))):
if i < len(scores) or generate_random_scores:
@@ -401,8 +339,8 @@ class AssignmentFormatGrader(CourseGrader):
section_name = "Generated"
else:
earned = scores[i].earned
possible = scores[i].possible
earned = scores[i].graded_total.earned
possible = scores[i].graded_total.possible
section_name = scores[i].display_name
percentage = earned / possible
@@ -442,8 +380,7 @@ class AssignmentFormatGrader(CourseGrader):
if len(breakdown) == 1:
# if there is only one entry in a section, suppress the existing individual entry and the average,
# and just display a single entry for the section. That way it acts automatically like a
# SingleSectionGrader.
# and just display a single entry for the section.
total_detail = u"{section_type} = {percent:.0%}".format(
percent=total_percent,
section_type=self.section_type,

View File

@@ -1,4 +1,5 @@
"""Grading tests"""
import ddt
import unittest
from xmodule import graders
@@ -12,13 +13,11 @@ class GradesheetTest(unittest.TestCase):
def test_weighted_grading(self):
scores = []
agg_fields = dict(display_name="aggregated_score", module_id=None, attempted=False)
prob_fields = dict(
display_name="problem_score", module_id=None, raw_earned=0, raw_possible=0, weight=0, attempted=False,
)
agg_fields = dict(attempted=False)
prob_fields = dict(raw_earned=0, raw_possible=0, weight=0, attempted=False)
# No scores
all_total, graded_total = aggregate_scores(scores, display_name=agg_fields['display_name'])
all_total, graded_total = aggregate_scores(scores)
self.assertEqual(
all_total,
AggregatedScore(tw_earned=0, tw_possible=0, graded=False, **agg_fields),
@@ -30,7 +29,7 @@ class GradesheetTest(unittest.TestCase):
# (0/5 non-graded)
scores.append(ProblemScore(weighted_earned=0, weighted_possible=5, graded=False, **prob_fields))
all_total, graded_total = aggregate_scores(scores, display_name=agg_fields['display_name'])
all_total, graded_total = aggregate_scores(scores)
self.assertEqual(
all_total,
AggregatedScore(tw_earned=0, tw_possible=5, graded=False, **agg_fields),
@@ -44,7 +43,7 @@ class GradesheetTest(unittest.TestCase):
prob_fields['attempted'] = True
agg_fields['attempted'] = True
scores.append(ProblemScore(weighted_earned=3, weighted_possible=5, graded=True, **prob_fields))
all_total, graded_total = aggregate_scores(scores, display_name=agg_fields['display_name'])
all_total, graded_total = aggregate_scores(scores)
self.assertAlmostEqual(
all_total,
AggregatedScore(tw_earned=3, tw_possible=10, graded=False, **agg_fields),
@@ -56,7 +55,7 @@ class GradesheetTest(unittest.TestCase):
# (0/5 non-graded) + (3/5 graded) + (2/5 graded) = 5/15 total, 5/10 graded
scores.append(ProblemScore(weighted_earned=2, weighted_possible=5, graded=True, **prob_fields))
all_total, graded_total = aggregate_scores(scores, display_name=agg_fields['display_name'])
all_total, graded_total = aggregate_scores(scores)
self.assertAlmostEqual(
all_total,
AggregatedScore(tw_earned=5, tw_possible=15, graded=False, **agg_fields),
@@ -67,6 +66,7 @@ class GradesheetTest(unittest.TestCase):
)
@ddt.ddt
class GraderTest(unittest.TestCase):
"""
Tests grader implementations
@@ -76,55 +76,48 @@ class GraderTest(unittest.TestCase):
}
incomplete_gradesheet = {
'Homework': [],
'Lab': [],
'Midterm': [],
'Homework': {},
'Lab': {},
'Midterm': {},
}
common_fields = dict(graded=True, module_id=None, attempted=True)
class MockGrade(object):
"""
Mock class for SubsectionGrade object.
"""
def __init__(self, graded_total, display_name):
self.graded_total = graded_total
self.display_name = display_name
common_fields = dict(graded=True, attempted=True)
test_gradesheet = {
'Homework': [
AggregatedScore(tw_earned=2, tw_possible=20.0, display_name='hw1', **common_fields),
AggregatedScore(tw_earned=16, tw_possible=16.0, display_name='hw2', **common_fields),
],
'Homework': {
'hw1': MockGrade(AggregatedScore(tw_earned=2, tw_possible=20.0, **common_fields), display_name='hw1'),
'hw2': MockGrade(AggregatedScore(tw_earned=16, tw_possible=16.0, **common_fields), display_name='hw2'),
},
# The dropped scores should be from the assignments that don't exist yet
'Lab': [
AggregatedScore(tw_earned=1, tw_possible=2.0, display_name='lab1', **common_fields), # Dropped
AggregatedScore(tw_earned=1, tw_possible=1.0, display_name='lab2', **common_fields),
AggregatedScore(tw_earned=1, tw_possible=1.0, display_name='lab3', **common_fields),
AggregatedScore(tw_earned=5, tw_possible=25.0, display_name='lab4', **common_fields), # Dropped
AggregatedScore(tw_earned=3, tw_possible=4.0, display_name='lab5', **common_fields), # Dropped
AggregatedScore(tw_earned=6, tw_possible=7.0, display_name='lab6', **common_fields),
AggregatedScore(tw_earned=5, tw_possible=6.0, display_name='lab7', **common_fields),
],
'Lab': {
# Dropped
'lab1': MockGrade(AggregatedScore(tw_earned=1, tw_possible=2.0, **common_fields), display_name='lab1'),
'lab2': MockGrade(AggregatedScore(tw_earned=1, tw_possible=1.0, **common_fields), display_name='lab2'),
'lab3': MockGrade(AggregatedScore(tw_earned=1, tw_possible=1.0, **common_fields), display_name='lab3'),
# Dropped
'lab4': MockGrade(AggregatedScore(tw_earned=5, tw_possible=25.0, **common_fields), display_name='lab4'),
# Dropped
'lab5': MockGrade(AggregatedScore(tw_earned=3, tw_possible=4.0, **common_fields), display_name='lab5'),
'lab6': MockGrade(AggregatedScore(tw_earned=6, tw_possible=7.0, **common_fields), display_name='lab6'),
'lab7': MockGrade(AggregatedScore(tw_earned=5, tw_possible=6.0, **common_fields), display_name='lab7'),
},
'Midterm': [
AggregatedScore(tw_earned=50.5, tw_possible=100, display_name="Midterm Exam", **common_fields),
],
'Midterm': {
'midterm': MockGrade(
AggregatedScore(tw_earned=50.5, tw_possible=100, **common_fields),
display_name="Midterm Exam",
),
},
}
def test_single_section_grader(self):
midterm_grader = graders.SingleSectionGrader("Midterm", "Midterm Exam")
lab4_grader = graders.SingleSectionGrader("Lab", "lab4")
bad_lab_grader = graders.SingleSectionGrader("Lab", "lab42")
for graded in [
midterm_grader.grade(self.empty_gradesheet),
midterm_grader.grade(self.incomplete_gradesheet),
bad_lab_grader.grade(self.test_gradesheet),
]:
self.assertEqual(len(graded['section_breakdown']), 1)
self.assertEqual(graded['percent'], 0.0)
graded = midterm_grader.grade(self.test_gradesheet)
self.assertAlmostEqual(graded['percent'], 0.505)
self.assertEqual(len(graded['section_breakdown']), 1)
graded = lab4_grader.grade(self.test_gradesheet)
self.assertAlmostEqual(graded['percent'], 0.2)
self.assertEqual(len(graded['section_breakdown']), 1)
def test_assignment_format_grader(self):
homework_grader = graders.AssignmentFormatGrader("Homework", 12, 2)
no_drop_grader = graders.AssignmentFormatGrader("Homework", 12, 0)
@@ -179,8 +172,6 @@ class GraderTest(unittest.TestCase):
# First, a few sub graders
homework_grader = graders.AssignmentFormatGrader("Homework", 12, 2)
lab_grader = graders.AssignmentFormatGrader("Lab", 7, 3)
# phasing out the use of SingleSectionGraders, and instead using AssignmentFormatGraders that
# will act like SingleSectionGraders on single sections.
midterm_grader = graders.AssignmentFormatGrader("Midterm", 1, 0)
weighted_grader = graders.WeightedSubsectionsGrader([
@@ -268,6 +259,8 @@ class GraderTest(unittest.TestCase):
},
{
'type': "Midterm",
'min_count': 0,
'drop_count': 0,
'name': "Midterm Exam",
'short_label': "Midterm",
'weight': 0.5,
@@ -294,5 +287,25 @@ class GraderTest(unittest.TestCase):
self.assertAlmostEqual(graded['percent'], 0.11)
self.assertEqual(len(graded['section_breakdown']), 12 + 1)
# TODO: How do we test failure cases? The parser only logs an error when
# it can't parse something. Maybe it should throw exceptions?
@ddt.data(
(
# empty
{},
u"Configuration has no appropriate grader class."
),
(
# no min_count
{'type': "Homework", 'drop_count': 0},
u"Configuration has no appropriate grader class."
),
(
# no drop_count
{'type': "Homework", 'min_count': 0},
u"__init__() takes at least 4 arguments (3 given)"
),
)
@ddt.unpack
def test_grader_with_invalid_conf(self, invalid_conf, expected_error_message):
with self.assertRaises(ValueError) as error:
graders.grader_from_conf([invalid_conf])
self.assertIn(expected_error_message, error.exception.message)