From 60caf0aa1079d75e62293d497fba20fa499bf1ee Mon Sep 17 00:00:00 2001 From: Bridger Maxwell Date: Thu, 5 Apr 2012 16:56:16 -0400 Subject: [PATCH 01/22] Added grades SectionPercentage namedtuple. Fixed bug where problem ids were being used for section labels in graph. --- djangoapps/courseware/grades.py | 14 ++++++++------ templates/profile.html | 2 +- templates/profile_graphs.js | 8 ++++---- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/djangoapps/courseware/grades.py b/djangoapps/courseware/grades.py index c7e67143b0..21235f8bd1 100644 --- a/djangoapps/courseware/grades.py +++ b/djangoapps/courseware/grades.py @@ -13,6 +13,8 @@ from student.models import UserProfile log = logging.getLogger("mitx.courseware") Score = namedtuple("Score", "earned possible graded section") +SectionPercentage = namedtuple("SectionPercentage", "percentage label summary") + def get_grade(user, problem, cache): ## HACK: assumes max score is fixed per problem @@ -107,12 +109,12 @@ def grade_sheet(student): section_total = Score(sum([score.earned for score in scores]), sum([score.possible for score in scores]), False, - p.get("id")) + s.get("name")) graded_total = Score(sum([score.earned for score in scores if score.graded]), sum([score.possible for score in scores if score.graded]), True, - p.get("id")) + s.get("name")) #Add the graded total to totaled_scores format = s.get('format') if s.get('format') else "" @@ -152,13 +154,13 @@ def grade_summary_6002x(totaled_scores): def totalWithDrops(scores, drop_count): #Note that this key will sort the list descending - sorted_scores = sorted( enumerate(scores), key=lambda x: -x[1]['percentage'] ) + sorted_scores = sorted( enumerate(scores), key=lambda x: -x[1].percentage ) # A list of the indices of the dropped scores dropped_indices = [score[0] for score in sorted_scores[-drop_count:]] aggregate_score = 0 for index, score in enumerate(scores): if index not in dropped_indices: - aggregate_score += score['percentage'] + aggregate_score += score.percentage aggregate_score /= len(scores) - drop_count @@ -183,7 +185,7 @@ def grade_summary_6002x(totaled_scores): label = "HW {0:02d}".format(i + 1) - homework_percentages.append( {'percentage': percentage, 'summary': summary, 'label' : label} ) + homework_percentages.append(SectionPercentage(percentage, label, summary) ) homework_total, homework_dropped_indices = totalWithDrops(homework_percentages, 2) #Figure the lab scores @@ -205,7 +207,7 @@ def grade_summary_6002x(totaled_scores): label = "Lab {0:02d}".format(i + 1) - lab_percentages.append( {'percentage': percentage, 'summary': summary, 'label' : label} ) + lab_percentages.append(SectionPercentage(percentage, label, summary) ) lab_total, lab_dropped_indices = totalWithDrops(lab_percentages, 2) diff --git a/templates/profile.html b/templates/profile.html index 6d15a14444..9e2e880556 100644 --- a/templates/profile.html +++ b/templates/profile.html @@ -150,7 +150,7 @@ $(function() { <% earned = section['section_total'].earned total = section['section_total'].possible - percentageString = "{0:.0%}".format( float(earned)/total) if earned > 0 else "" + percentageString = "{0:.0%}".format( float(earned)/total) if earned > 0 and total > 0 else "" %>

diff --git a/templates/profile_graphs.js b/templates/profile_graphs.js index b34a5d1636..d03e7520be 100644 --- a/templates/profile_graphs.js +++ b/templates/profile_graphs.js @@ -22,7 +22,7 @@ $(function () { <% colors = ["#b72121", "#600101", "#666666", "#333333"] - + #' tickIndex = 1 sectionSpacer = 0.5 sectionIndex = 0 @@ -38,13 +38,13 @@ $(function () { if 'subscores' in section: ##This is for sections like labs or homeworks, with several smaller components and a total series.append({ 'label' : section['category'], - 'data' : [[i + tickIndex, score['percentage']] for i,score in enumerate(section['subscores'])], + 'data' : [[i + tickIndex, score.percentage] for i,score in enumerate(section['subscores'])], 'color' : colors[sectionIndex] }) - ticks += [[i + tickIndex, score['label'] ] for i,score in enumerate(section['subscores'])] + ticks += [[i + tickIndex, score.label ] for i,score in enumerate(section['subscores'])] bottomTicks.append( [tickIndex + len(section['subscores'])/2, section['category']] ) - detail_tooltips[ section['category'] ] = [score['summary'] for score in section['subscores']] + detail_tooltips[ section['category'] ] = [score.summary for score in section['subscores']] droppedScores += [[tickIndex + index, 0.05] for index in section['dropped_indices']] From a0eb072aff10a064d2109eff8ebbd06e3d18abe8 Mon Sep 17 00:00:00 2001 From: Bridger Maxwell Date: Thu, 5 Apr 2012 21:29:38 -0400 Subject: [PATCH 02/22] Refactoring grades. Adding more generic grading classes. Probably broken right now. Just committing to save progress. --- djangoapps/courseware/grades.py | 114 ++++++++++++++++++++++++++++++-- 1 file changed, 110 insertions(+), 4 deletions(-) diff --git a/djangoapps/courseware/grades.py b/djangoapps/courseware/grades.py index 21235f8bd1..0aa3dc57c8 100644 --- a/djangoapps/courseware/grades.py +++ b/djangoapps/courseware/grades.py @@ -16,7 +16,113 @@ Score = namedtuple("Score", "earned possible graded section") SectionPercentage = namedtuple("SectionPercentage", "percentage label summary") -def get_grade(user, problem, cache): +class CourseGrader: + def grade(self, grade_sheet): + raise NotImplementedError + +class FormatWithDropsGrader(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). + + section_detail_formatter is a format string with the parameters (index, name, percent, earned, possible). + ex: "Homework {index} - {name} - {percent:.0%} ({earned:g}/{possible:g})" + + section_missing_detail_formatter is a format string with the parameters (index) for + when the minimum number of sections weren't found in the course. + ex: "Unreleased Homework {index} - 0% (?/?)" + + section_label_formatter is a format string for a short label with the parameters (index). + These look best when fixed-length. + ex: "HW {index:02d}" + + total_detail_formatter is a format string for displaying the average score with the + parameters (percent). + ex: "Homework Average = {percent:.0%}" + + total_label_formatter is a string (with no parameters). + ex: "HW Avg" + + """ + def __init__(self, course_format, min_number, drop_count, category, section_detail_formatter, section_missing_detail_formatter, + section_label_formatter, total_detail_formatter, total_label_formatter): + + self.course_format = course_format + self.min_number = min_number + self.drop_count = drop_count + self.category = category + self.section_detail_formatter = section_detail_formatter + self.section_missing_detail_formatter = section_missing_detail_formatter + self.section_label_formatter = section_label_formatter + self.total_detail_formatter = total_detail_formatter + self.total_label_formatter = total_label_formatter + + + def grade(self, grade_sheet): + def totalWithDrops(breakdown, drop_count): + #create an array of tuples with (index, mark), sorted by mark['percentage'] descending + sorted_breakdown = sorted( enumerate(breakdown), key=lambda x: -x[1]['percentage'] ) + # 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['percentage'] + + 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 = self.section_detail_formatter.format(index: i+1, + name: scores[i].section, + percentage: percentage, + earned: scores[i].earned, + possible: scores[i].possible ) + else: + percentage = 0 + summary = self.section_missing_detail_formatter.format(index: i+1) + + 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 = self.section_detail_formatter.format(index: i+1, + name: "Randomly Generated", + percentage: percentage, + earned: points_earned, + possible: points_possible ) + + label = self.section_label_formatter.format(index: i+1) + + + breakdown.append( {'percent': percentage, 'label': label, 'detail': summary, category: self.category} ) + + total_percent, dropped_indices = totalWithDrops(breakdown, self.drop_count) + + for dropped_index in dropped_indicies: + breakdown[dropped_index]['mark'] = {'detail': "The lowest {0} scores are dropped.".format(self.drop_count) } + + + total_detail = self.total_detail_formatter.format(percent: total_percent) + breakdown.append( {'percent': total_percent, 'label': self.total_label_formatter, '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 @@ -87,7 +193,7 @@ def grade_sheet(student): scores=[] if len(problems)>0: for p in problems: - (correct,total) = get_grade(student, p, response_by_id) + (correct,total) = get_score(student, p, response_by_id) # id = p.get('id') # correct = 0 # if id in response_by_id: @@ -124,7 +230,7 @@ def grade_sheet(student): format_scores.append( graded_total ) totaled_scores[ format ] = format_scores - score={'section':s.get("name"), + section_score={'section':s.get("name"), 'scores':scores, 'section_total' : section_total, 'format' : format, @@ -132,7 +238,7 @@ def grade_sheet(student): 'due' : s.get("due") or "", 'graded' : graded, } - sections.append(score) + sections.append(section_score) chapters.append({'course':course, 'chapter' : c.get("name"), From b0e2ea3a631591d4a74e4df8e37ecb963cc586c2 Mon Sep 17 00:00:00 2001 From: Bridger Maxwell Date: Fri, 6 Apr 2012 15:04:49 -0400 Subject: [PATCH 03/22] Still working on grades refactor. Not working yet, but reached a checkpoint. --- djangoapps/courseware/grades.py | 46 ++++++++-------- templates/profile_graphs.js | 94 +++++++++------------------------ 2 files changed, 49 insertions(+), 91 deletions(-) diff --git a/djangoapps/courseware/grades.py b/djangoapps/courseware/grades.py index 0aa3dc57c8..189ba0e46b 100644 --- a/djangoapps/courseware/grades.py +++ b/djangoapps/courseware/grades.py @@ -19,7 +19,7 @@ SectionPercentage = namedtuple("SectionPercentage", "percentage label summary") class CourseGrader: def grade(self, grade_sheet): raise NotImplementedError - + class FormatWithDropsGrader(CourseGrader): """ Grades all sections specified in course_format with an equal weight. A specified @@ -62,14 +62,14 @@ class FormatWithDropsGrader(CourseGrader): def grade(self, grade_sheet): def totalWithDrops(breakdown, drop_count): - #create an array of tuples with (index, mark), sorted by mark['percentage'] descending - sorted_breakdown = sorted( enumerate(breakdown), key=lambda x: -x[1]['percentage'] ) + #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['percentage'] + aggregate_score += mark['percent'] aggregate_score /= len(scores) - drop_count @@ -81,38 +81,38 @@ class FormatWithDropsGrader(CourseGrader): for i in range(12): if i < len(scores): percentage = scores[i].earned / float(scores[i].possible) - summary = self.section_detail_formatter.format(index: i+1, - name: scores[i].section, - percentage: percentage, - earned: scores[i].earned, - possible: scores[i].possible ) + summary = self.section_detail_formatter.format(index = i+1, + name = scores[i].section, + percent = percentage, + earned = scores[i].earned, + possible = scores[i].possible ) else: percentage = 0 - summary = self.section_missing_detail_formatter.format(index: i+1) + summary = self.section_missing_detail_formatter.format(index = i+1) 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 = self.section_detail_formatter.format(index: i+1, - name: "Randomly Generated", - percentage: percentage, - earned: points_earned, - possible: points_possible ) + summary = self.section_detail_formatter.format(index = i+1, + name = "Randomly Generated", + percent = percentage, + earned = points_earned, + possible = points_possible ) - label = self.section_label_formatter.format(index: i+1) + label = self.section_label_formatter.format(index = i+1) - breakdown.append( {'percent': percentage, 'label': label, 'detail': summary, category: self.category} ) + breakdown.append( {'percent': percentage, 'label': label, 'detail': summary, 'category': self.category} ) total_percent, dropped_indices = totalWithDrops(breakdown, self.drop_count) - for dropped_index in dropped_indicies: + for dropped_index in dropped_indices: breakdown[dropped_index]['mark'] = {'detail': "The lowest {0} scores are dropped.".format(self.drop_count) } - total_detail = self.total_detail_formatter.format(percent: total_percent) - breakdown.append( {'percent': total_percent, 'label': self.total_label_formatter, 'detail': total_detail, category: self.category, prominent: True} ) + total_detail = self.total_detail_formatter.format(percent = total_percent) + breakdown.append( {'percent': total_percent, 'label': self.total_label_formatter, 'detail': total_detail, 'category': self.category, 'prominent': True} ) return {'percent' : total_percent, @@ -245,7 +245,11 @@ def grade_sheet(student): 'sections' : sections,}) - grade_summary = grade_summary_6002x(totaled_scores) + grader = FormatWithDropsGrader("Homework", 12, 2, "Homework", "Homework {index} - {name} - {percent:.0%} ({earned:g}/{possible:g})", + "Unreleased Homework {index} - 0% (?/?)", "HW {index:02d}", "Homework Average = {percent:.0%}", "HW Avg") + + + grade_summary = grader.grade(totaled_scores) return {'courseware_summary' : chapters, 'grade_summary' : grade_summary} diff --git a/templates/profile_graphs.js b/templates/profile_graphs.js index d03e7520be..c43c140e60 100644 --- a/templates/profile_graphs.js +++ b/templates/profile_graphs.js @@ -22,6 +22,7 @@ $(function () { <% colors = ["#b72121", "#600101", "#666666", "#333333"] + categories = {} #' tickIndex = 1 sectionSpacer = 0.5 @@ -34,78 +35,31 @@ $(function () { droppedScores = [] #These are the datapoints to indicate assignments which aren't factored into the total score dropped_score_tooltips = [] - for section in grade_summary: - if 'subscores' in section: ##This is for sections like labs or homeworks, with several smaller components and a total - series.append({ - 'label' : section['category'], - 'data' : [[i + tickIndex, score.percentage] for i,score in enumerate(section['subscores'])], - 'color' : colors[sectionIndex] - }) - - ticks += [[i + tickIndex, score.label ] for i,score in enumerate(section['subscores'])] - bottomTicks.append( [tickIndex + len(section['subscores'])/2, section['category']] ) - detail_tooltips[ section['category'] ] = [score.summary for score in section['subscores']] - - droppedScores += [[tickIndex + index, 0.05] for index in section['dropped_indices']] - - dropExplanation = "The lowest {0} {1} scores are dropped".format( len(section['dropped_indices']), section['category'] ) - dropped_score_tooltips += [dropExplanation] * len(section['dropped_indices']) - - - tickIndex += len(section['subscores']) + sectionSpacer - - - category_total_label = section['category'] + " Total" - series.append({ - 'label' : category_total_label, - 'data' : [ [tickIndex, section['totalscore']] ], - 'color' : colors[sectionIndex] - }) - - ticks.append( [tickIndex, section['totallabel']] ) - detail_tooltips[category_total_label] = [section['totalscore_summary']] - else: - series.append({ - 'label' : section['category'], - 'data' : [ [tickIndex, section['totalscore']] ], - 'color' : colors[sectionIndex] - }) - - ticks.append( [tickIndex, section['totallabel']] ) - detail_tooltips[section['category']] = [section['totalscore_summary']] - - tickIndex += 1 + sectionSpacer - sectionIndex += 1 - - - detail_tooltips['Dropped Scores'] = dropped_score_tooltips - - ## ----------------------------- Grade overviewew bar ------------------------- ## - totalWeight = 0.0 - sectionIndex = 0 - totalScore = 0.0 - overviewBarX = tickIndex - - for section in grade_summary: - weighted_score = section['totalscore'] * section['weight'] - summary_text = "{0} - {1:.1%} of a possible {2:.0%}".format(section['category'], weighted_score, section['weight']) - - weighted_category_label = section['category'] + " - Weighted" - - if section['totalscore'] > 0: - series.append({ - 'label' : weighted_category_label, - 'data' : [ [overviewBarX, weighted_score] ], - 'color' : colors[sectionIndex] - }) + for section in grade_summary['section_breakdown']: + if section.get('prominent', False): + tickIndex += sectionSpacer - detail_tooltips[weighted_category_label] = [ summary_text ] - sectionIndex += 1 - totalWeight += section['weight'] - totalScore += section['totalscore'] * section['weight'] + if section['category'] not in categories: + colorIndex = len(categories) % len(colors) + categories[ section['category'] ] = {'label' : section['category'], + 'data' : [], + 'color' : colors[colorIndex]} - ticks += [ [overviewBarX, "Total"] ] - tickIndex += 1 + sectionSpacer + categoryData = categories[ section['category'] ] + + categoryData['data'].append( [tickIndex, section['percent']] ) + ticks.append( [tickIndex, section['label'] ] ) + + + if section['category'] in detail_tooltips: + detail_tooltips[ section['category'] ].append( section['detail'] ) + else: + detail_tooltips[ section['category'] ] = [ section['detail'], ] + + tickIndex += 1 + + if section.get('prominent', False): + tickIndex += sectionSpacer %> var series = ${ json.dumps(series) }; From 04221210de954717aeb36f77a3631dabc24b2783 Mon Sep 17 00:00:00 2001 From: Bridger Maxwell Date: Fri, 6 Apr 2012 15:47:21 -0400 Subject: [PATCH 04/22] Weighted section grading is mostly working, but the final grade isn't on the graph yet. --- djangoapps/courseware/grades.py | 36 ++++++++++++++++++++++++++++++++- templates/profile_graphs.js | 16 +++++++-------- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/djangoapps/courseware/grades.py b/djangoapps/courseware/grades.py index 189ba0e46b..4f3db888ed 100644 --- a/djangoapps/courseware/grades.py +++ b/djangoapps/courseware/grades.py @@ -119,7 +119,36 @@ class FormatWithDropsGrader(CourseGrader): 'section_breakdown' : breakdown, #No grade_breakdown here } + +class WeightedSubsectionsGrader(CourseGrader): + """ + This grader takes a list of tuples containing (grader, section_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) ] + """ + 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) + section_category = "{0} - Weighted".format(section_name) + + total_percent += weightedPercent + section_breakdown += subgrade_result['section_breakdown'] + grade_breakdown.append( {'percent' : weightedPercent, 'detail' : section_detail, 'category' : section_category} ) + + return {'percent' : total_percent, + 'section_breakdown' : section_breakdown, + 'grade_breakdown' : grade_breakdown} def get_score(user, problem, cache): @@ -245,8 +274,13 @@ def grade_sheet(student): 'sections' : sections,}) - grader = FormatWithDropsGrader("Homework", 12, 2, "Homework", "Homework {index} - {name} - {percent:.0%} ({earned:g}/{possible:g})", + hwGrader = FormatWithDropsGrader("Homework", 12, 2, "Homework", "Homework {index} - {name} - {percent:.0%} ({earned:g}/{possible:g})", "Unreleased Homework {index} - 0% (?/?)", "HW {index:02d}", "Homework Average = {percent:.0%}", "HW Avg") + + labGrader = FormatWithDropsGrader("Lab", 12, 2, "Labs", "Lab {index} - {name} - {percent:.0%} ({earned:g}/{possible:g})", + "Unreleased Lab {index} - 0% (?/?)", "Lab {index:02d}", "Lab Average = {percent:.0%}", "Lab Avg") + + grader = WeightedSubsectionsGrader( [(hwGrader, "Homework", 0.15), (labGrader, "Labs", 0.15)] ) grade_summary = grader.grade(totaled_scores) diff --git a/templates/profile_graphs.js b/templates/profile_graphs.js index c43c140e60..dad0ee0001 100644 --- a/templates/profile_graphs.js +++ b/templates/profile_graphs.js @@ -19,20 +19,19 @@ $(function () { } /* -------------------------------- Grade detail bars -------------------------------- */ - + <% colors = ["#b72121", "#600101", "#666666", "#333333"] categories = {} - #' + tickIndex = 1 sectionSpacer = 0.5 sectionIndex = 0 - series = [] ticks = [] #These are the indices and x-axis labels for the data bottomTicks = [] #Labels on the bottom detail_tooltips = {} #This an dictionary mapping from 'section' -> array of detail_tooltips - droppedScores = [] #These are the datapoints to indicate assignments which aren't factored into the total score + droppedScores = [] #These are the datapoints to indicate assignments which are not factored into the total score dropped_score_tooltips = [] for section in grade_summary['section_breakdown']: @@ -44,7 +43,7 @@ $(function () { categories[ section['category'] ] = {'label' : section['category'], 'data' : [], 'color' : colors[colorIndex]} - + categoryData = categories[ section['category'] ] categoryData['data'].append( [tickIndex, section['percent']] ) @@ -60,9 +59,10 @@ $(function () { if section.get('prominent', False): tickIndex += sectionSpacer + %> - var series = ${ json.dumps(series) }; + var series = ${ json.dumps( categories.values() ) }; var ticks = ${ json.dumps(ticks) }; var bottomTicks = ${ json.dumps(bottomTicks) }; var detail_tooltips = ${ json.dumps(detail_tooltips) }; @@ -86,9 +86,7 @@ $(function () { var $grade_detail_graph = $("#${graph_div_id}"); if ($grade_detail_graph.length > 0) { var plot = $.plot($grade_detail_graph, series, options); - - var o = plot.pointOffset({x: ${overviewBarX} , y: ${totalScore}}); - $grade_detail_graph.append('
${"{totalscore:.0%}".format(totalscore=totalScore)}
'); + //We need to put back the plotting of the percent here } var previousPoint = null; From 779c247512f95148a1d63275cc54c2cc357ab05b Mon Sep 17 00:00:00 2001 From: Bridger Maxwell Date: Sat, 7 Apr 2012 19:21:19 -0400 Subject: [PATCH 05/22] Weighted scores now working in the refactor and show up correctly on the profile graph. Dropped score markers are back too. --- djangoapps/courseware/grades.py | 3 +-- templates/profile_graphs.js | 37 +++++++++++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/djangoapps/courseware/grades.py b/djangoapps/courseware/grades.py index 4f3db888ed..94770749f8 100644 --- a/djangoapps/courseware/grades.py +++ b/djangoapps/courseware/grades.py @@ -140,11 +140,10 @@ class WeightedSubsectionsGrader(CourseGrader): weightedPercent = subgrade_result['percent'] * weight section_detail = "{0} = {1:.1%} of a possible {2:.0%}".format(section_name, weightedPercent, weight) - section_category = "{0} - Weighted".format(section_name) total_percent += weightedPercent section_breakdown += subgrade_result['section_breakdown'] - grade_breakdown.append( {'percent' : weightedPercent, 'detail' : section_detail, 'category' : section_category} ) + grade_breakdown.append( {'percent' : weightedPercent, 'detail' : section_detail, 'category' : section_name} ) return {'percent' : total_percent, 'section_breakdown' : section_breakdown, diff --git a/templates/profile_graphs.js b/templates/profile_graphs.js index dad0ee0001..2447417958 100644 --- a/templates/profile_graphs.js +++ b/templates/profile_graphs.js @@ -49,20 +49,51 @@ $(function () { categoryData['data'].append( [tickIndex, section['percent']] ) ticks.append( [tickIndex, section['label'] ] ) - if section['category'] in detail_tooltips: detail_tooltips[ section['category'] ].append( section['detail'] ) else: detail_tooltips[ section['category'] ] = [ section['detail'], ] + + if 'mark' in section: + droppedScores.append( [tickIndex, 0.05] ) + dropped_score_tooltips.append( section['mark']['detail'] ) tickIndex += 1 if section.get('prominent', False): tickIndex += sectionSpacer + + ## ----------------------------- Grade overviewew bar ------------------------- ## + tickIndex += sectionSpacer + series = categories.values() + overviewBarX = tickIndex + extraColorIndex = len(categories) #Keeping track of the next color to use for categories not in categories[] + + for section in grade_summary['grade_breakdown']: + if section['percent'] > 0: + if section['category'] in categories: + color = categories[ section['category'] ]['color'] + else: + color = colors[ extraColorIndex % len(colors) ] + extraColorIndex += 1 + + series.append({ + 'label' : section['category'] + "-grade_breakdown", + 'data' : [ [overviewBarX, section['percent']] ], + 'color' : color + }) + + detail_tooltips[section['category'] + "-grade_breakdown"] = [ section['detail'] ] + + ticks += [ [overviewBarX, "Total"] ] + tickIndex += 1 + sectionSpacer + + totalScore = grade_summary['percent'] + detail_tooltips['Dropped Scores'] = dropped_score_tooltips %> - var series = ${ json.dumps( categories.values() ) }; + var series = ${ json.dumps( series ) }; var ticks = ${ json.dumps(ticks) }; var bottomTicks = ${ json.dumps(bottomTicks) }; var detail_tooltips = ${ json.dumps(detail_tooltips) }; @@ -87,6 +118,8 @@ $(function () { if ($grade_detail_graph.length > 0) { var plot = $.plot($grade_detail_graph, series, options); //We need to put back the plotting of the percent here + var o = plot.pointOffset({x: ${overviewBarX} , y: ${totalScore}}); + $grade_detail_graph.append('
${"{totalscore:.0%}".format(totalscore=totalScore)}
'); } var previousPoint = null; From c1e6dd800506e2d18aedc79db96f18aa893b9f11 Mon Sep 17 00:00:00 2001 From: Bridger Maxwell Date: Sun, 8 Apr 2012 00:38:00 -0400 Subject: [PATCH 06/22] Added single section grader. Midterm and Final are now graded. --- djangoapps/courseware/grades.py | 58 ++++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/djangoapps/courseware/grades.py b/djangoapps/courseware/grades.py index 94770749f8..4e5b8f5497 100644 --- a/djangoapps/courseware/grades.py +++ b/djangoapps/courseware/grades.py @@ -19,13 +19,56 @@ SectionPercentage = namedtuple("SectionPercentage", "percentage label summary") class CourseGrader: def grade(self, grade_sheet): raise NotImplementedError + +class SingleSectionGrader(CourseGrader): + """ + This grades a single section with the format section_format and the name section_name. + """ + def __init__(self, section_format, section_name, label = None, category = None): + self.section_format = section_format + self.section_name = section_name + self.label = 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.label, 'detail': detail, 'category': self.category, 'prominent': True}] + + return {'percent' : percent, + 'section_breakdown' : breakdown, + #No grade_breakdown here + } + + + -class FormatWithDropsGrader(CourseGrader): +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). + 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_detail_formatter is a format string with the parameters (index, name, percent, earned, possible). ex: "Homework {index} - {name} - {percent:.0%} ({earned:g}/{possible:g})" @@ -273,13 +316,18 @@ def grade_sheet(student): 'sections' : sections,}) - hwGrader = FormatWithDropsGrader("Homework", 12, 2, "Homework", "Homework {index} - {name} - {percent:.0%} ({earned:g}/{possible:g})", + hwGrader = AssignmentFormatGrader("Homework", 12, 2, "Homework", "Homework {index} - {name} - {percent:.0%} ({earned:g}/{possible:g})", "Unreleased Homework {index} - 0% (?/?)", "HW {index:02d}", "Homework Average = {percent:.0%}", "HW Avg") - labGrader = FormatWithDropsGrader("Lab", 12, 2, "Labs", "Lab {index} - {name} - {percent:.0%} ({earned:g}/{possible:g})", + labGrader = AssignmentFormatGrader("Lab", 12, 2, "Labs", "Lab {index} - {name} - {percent:.0%} ({earned:g}/{possible:g})", "Unreleased Lab {index} - 0% (?/?)", "Lab {index:02d}", "Lab Average = {percent:.0%}", "Lab Avg") + + midtermGrader = SingleSectionGrader("Examination", "Midterm Exam", "Midterm") - grader = WeightedSubsectionsGrader( [(hwGrader, "Homework", 0.15), (labGrader, "Labs", 0.15)] ) + 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) From 245a3d97a819a3c02dd440e2123a457a69fad180 Mon Sep 17 00:00:00 2001 From: Bridger Maxwell Date: Sun, 8 Apr 2012 01:58:21 -0400 Subject: [PATCH 07/22] Simplified the parameters for AssignmentFormatGrader --- djangoapps/courseware/grades.py | 402 +++++++++++--------------------- templates/profile_graphs.js | 2 +- 2 files changed, 133 insertions(+), 271 deletions(-) diff --git a/djangoapps/courseware/grades.py b/djangoapps/courseware/grades.py index 4e5b8f5497..0baec02a33 100644 --- a/djangoapps/courseware/grades.py +++ b/djangoapps/courseware/grades.py @@ -19,156 +19,15 @@ SectionPercentage = namedtuple("SectionPercentage", "percentage label summary") class CourseGrader: def grade(self, grade_sheet): raise NotImplementedError - -class SingleSectionGrader(CourseGrader): - """ - This grades a single section with the format section_format and the name section_name. - """ - def __init__(self, section_format, section_name, label = None, category = None): - self.section_format = section_format - self.section_name = section_name - self.label = 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.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_detail_formatter is a format string with the parameters (index, name, percent, earned, possible). - ex: "Homework {index} - {name} - {percent:.0%} ({earned:g}/{possible:g})" - - section_missing_detail_formatter is a format string with the parameters (index) for - when the minimum number of sections weren't found in the course. - ex: "Unreleased Homework {index} - 0% (?/?)" - - section_label_formatter is a format string for a short label with the parameters (index). - These look best when fixed-length. - ex: "HW {index:02d}" - - total_detail_formatter is a format string for displaying the average score with the - parameters (percent). - ex: "Homework Average = {percent:.0%}" - - total_label_formatter is a string (with no parameters). - ex: "HW Avg" - - """ - def __init__(self, course_format, min_number, drop_count, category, section_detail_formatter, section_missing_detail_formatter, - section_label_formatter, total_detail_formatter, total_label_formatter): - - self.course_format = course_format - self.min_number = min_number - self.drop_count = drop_count - self.category = category - self.section_detail_formatter = section_detail_formatter - self.section_missing_detail_formatter = section_missing_detail_formatter - self.section_label_formatter = section_label_formatter - self.total_detail_formatter = total_detail_formatter - self.total_label_formatter = total_label_formatter - - - 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 = self.section_detail_formatter.format(index = i+1, - name = scores[i].section, - percent = percentage, - earned = scores[i].earned, - possible = scores[i].possible ) - else: - percentage = 0 - summary = self.section_missing_detail_formatter.format(index = i+1) - - 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 = self.section_detail_formatter.format(index = i+1, - name = "Randomly Generated", - percent = percentage, - earned = points_earned, - possible = points_possible ) - - label = self.section_label_formatter.format(index = i+1) - - - breakdown.append( {'percent': percentage, 'label': 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 {0} scores are dropped.".format(self.drop_count) } - - - total_detail = self.total_detail_formatter.format(percent = total_percent) - breakdown.append( {'percent': total_percent, 'label': self.total_label_formatter, 'detail': total_detail, 'category': self.category, 'prominent': True} ) - - - return {'percent' : total_percent, - 'section_breakdown' : breakdown, - #No grade_breakdown here - } - class WeightedSubsectionsGrader(CourseGrader): """ - This grader takes a list of tuples containing (grader, section_name, weight) and computes + 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 @@ -193,6 +52,133 @@ class WeightedSubsectionsGrader(CourseGrader): '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') @@ -316,14 +302,9 @@ def grade_sheet(student): 'sections' : sections,}) - hwGrader = AssignmentFormatGrader("Homework", 12, 2, "Homework", "Homework {index} - {name} - {percent:.0%} ({earned:g}/{possible:g})", - "Unreleased Homework {index} - 0% (?/?)", "HW {index:02d}", "Homework Average = {percent:.0%}", "HW Avg") - - labGrader = AssignmentFormatGrader("Lab", 12, 2, "Labs", "Lab {index} - {name} - {percent:.0%} ({earned:g}/{possible:g})", - "Unreleased Lab {index} - 0% (?/?)", "Lab {index:02d}", "Lab Average = {percent:.0%}", "Lab Avg") - + 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), @@ -334,122 +315,3 @@ def grade_sheet(student): return {'courseware_summary' : chapters, 'grade_summary' : grade_summary} - - -def grade_summary_6002x(totaled_scores): - """ - This function takes the a dictionary of (graded) section scores, and applies the course grading rules to create - the grade_summary. For 6.002x this means homeworks and labs all have equal weight, with the lowest 2 of each - being dropped. There is one midterm and one final. - """ - - def totalWithDrops(scores, drop_count): - #Note that this key will sort the list descending - sorted_scores = sorted( enumerate(scores), key=lambda x: -x[1].percentage ) - # A list of the indices of the dropped scores - dropped_indices = [score[0] for score in sorted_scores[-drop_count:]] - aggregate_score = 0 - for index, score in enumerate(scores): - if index not in dropped_indices: - aggregate_score += score.percentage - - aggregate_score /= len(scores) - drop_count - - return aggregate_score, dropped_indices - - #Figure the homework scores - homework_scores = totaled_scores['Homework'] if 'Homework' in totaled_scores else [] - homework_percentages = [] - for i in range(12): - if i < len(homework_scores): - percentage = homework_scores[i].earned / float(homework_scores[i].possible) - summary = "Homework {0} - {1} - {2:.0%} ({3:g}/{4:g})".format( i + 1, homework_scores[i].section , percentage, homework_scores[i].earned, homework_scores[i].possible ) - else: - percentage = 0 - summary = "Unreleased Homework {0} - 0% (?/?)".format(i + 1) - - 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 = "Random Homework - {0:.0%} ({1:g}/{2:g})".format( percentage, points_earned, points_possible ) - - label = "HW {0:02d}".format(i + 1) - - homework_percentages.append(SectionPercentage(percentage, label, summary) ) - homework_total, homework_dropped_indices = totalWithDrops(homework_percentages, 2) - - #Figure the lab scores - lab_scores = totaled_scores['Lab'] if 'Lab' in totaled_scores else [] - lab_percentages = [] - for i in range(12): - if i < len(lab_scores): - percentage = lab_scores[i].earned / float(lab_scores[i].possible) - summary = "Lab {0} - {1} - {2:.0%} ({3:g}/{4:g})".format( i + 1, lab_scores[i].section , percentage, lab_scores[i].earned, lab_scores[i].possible ) - else: - percentage = 0 - summary = "Unreleased Lab {0} - 0% (?/?)".format(i + 1) - - 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 = "Random Lab - {0:.0%} ({1:g}/{2:g})".format( percentage, points_earned, points_possible ) - - label = "Lab {0:02d}".format(i + 1) - - lab_percentages.append(SectionPercentage(percentage, label, summary) ) - lab_total, lab_dropped_indices = totalWithDrops(lab_percentages, 2) - - - #TODO: Pull this data about the midterm and final from the databse. It should be exactly similar to above, but we aren't sure how exams will be done yet. - midterm_score = Score('?', '?', True, "?") - midterm_percentage = 0 - - final_score = Score('?', '?', True, "?") - final_percentage = 0 - - if settings.GENERATE_PROFILE_SCORES: - midterm_score = Score(random.randrange(50, 150), 150, True, "?") - midterm_percentage = midterm_score.earned / float(midterm_score.possible) - - final_score = Score(random.randrange(100, 300), 300, True, "?") - final_percentage = final_score.earned / float(final_score.possible) - - - grade_summary = [ - { - 'category': 'Homework', - 'subscores' : homework_percentages, - 'dropped_indices' : homework_dropped_indices, - 'totalscore' : homework_total, - 'totalscore_summary' : "Homework Average - {0:.0%}".format(homework_total), - 'totallabel' : 'HW Avg', - 'weight' : 0.15, - }, - { - 'category': 'Labs', - 'subscores' : lab_percentages, - 'dropped_indices' : lab_dropped_indices, - 'totalscore' : lab_total, - 'totalscore_summary' : "Lab Average - {0:.0%}".format(lab_total), - 'totallabel' : 'Lab Avg', - 'weight' : 0.15, - }, - { - 'category': 'Midterm', - 'totalscore' : midterm_percentage, - 'totalscore_summary' : "Midterm - {0:.0%} ({1}/{2})".format(midterm_percentage, midterm_score.earned, midterm_score.possible), - 'totallabel' : 'Midterm', - 'weight' : 0.30, - }, - { - 'category': 'Final', - 'totalscore' : final_percentage, - 'totalscore_summary' : "Final - {0:.0%} ({1}/{2})".format(final_percentage, final_score.earned, final_score.possible), - 'totallabel' : 'Final', - 'weight' : 0.40, - } - ] - - return grade_summary diff --git a/templates/profile_graphs.js b/templates/profile_graphs.js index 2447417958..0921b1b516 100644 --- a/templates/profile_graphs.js +++ b/templates/profile_graphs.js @@ -25,7 +25,7 @@ $(function () { categories = {} tickIndex = 1 - sectionSpacer = 0.5 + sectionSpacer = 0.25 sectionIndex = 0 ticks = [] #These are the indices and x-axis labels for the data From 812810a670286555895f19a3f9f80b23369f9934 Mon Sep 17 00:00:00 2001 From: Bridger Maxwell Date: Fri, 13 Apr 2012 16:32:39 -0400 Subject: [PATCH 08/22] Converted gradebook to refactored grading format. --- djangoapps/courseware/grades.py | 2 +- templates/gradebook.html | 29 ++++++++++------------------- 2 files changed, 11 insertions(+), 20 deletions(-) diff --git a/djangoapps/courseware/grades.py b/djangoapps/courseware/grades.py index 0baec02a33..aab7e61954 100644 --- a/djangoapps/courseware/grades.py +++ b/djangoapps/courseware/grades.py @@ -16,7 +16,7 @@ Score = namedtuple("Score", "earned possible graded section") SectionPercentage = namedtuple("SectionPercentage", "percentage label summary") -class CourseGrader: +class CourseGrader(object): def grade(self, grade_sheet): raise NotImplementedError diff --git a/templates/gradebook.html b/templates/gradebook.html index cb25a5507a..37f8b07b25 100644 --- a/templates/gradebook.html +++ b/templates/gradebook.html @@ -8,7 +8,8 @@ @@ -29,16 +30,10 @@ Student - %for section in templateSummary: - %if 'subscores' in section: - %for subsection in section['subscores']: - ${subsection['label']} - %endfor - ${section['totallabel']} - %else: - ${section['category']} - %endif + %for section in templateSummary['section_breakdown']: + ${section['label']} %endfor + Total <%def name="percent_data(percentage)"> @@ -50,6 +45,8 @@ data_class = "grade_b" elif percentage > .6: data_class = "grade_c" + elif percentage > 0: + data_class = "grade_f" %> ${ "{0:.0%}".format( percentage ) } @@ -57,16 +54,10 @@ %for student in students:
${student['username']} - %for section in student['grade_info']['grade_summary']: - %if 'subscores' in section: - %for subsection in section['subscores']: - ${percent_data( subsection['percentage'] )} - %endfor - ${percent_data( section['totalscore'] )} - %else: - ${percent_data( section['totalscore'] )} - %endif + %for section in student['grade_info']['grade_summary']['section_breakdown']: + ${percent_data( section['percent'] )} %endfor + ${percent_data( student['grade_info']['grade_summary']['percent'])} %endfor From 03ea89f67f81a4011e0bdc3fe816023c669f7c61 Mon Sep 17 00:00:00 2001 From: Bridger Maxwell Date: Sat, 14 Apr 2012 18:42:20 -0400 Subject: [PATCH 09/22] Added some tests to the grading refactor. Fixed some bugs found during testing. --- djangoapps/courseware/grades.py | 69 +++++++++++------------ djangoapps/courseware/tests.py | 98 ++++++++++++++++++++++++++++++++- 2 files changed, 129 insertions(+), 38 deletions(-) diff --git a/djangoapps/courseware/grades.py b/djangoapps/courseware/grades.py index c47bd4d214..53bff94c64 100644 --- a/djangoapps/courseware/grades.py +++ b/djangoapps/courseware/grades.py @@ -75,10 +75,10 @@ class SingleSectionGrader(CourseGrader): if foundScore: percent = foundScore.earned / float(foundScore.possible) - detail = "{name} - {percent:.0%} ({earned:g}/{possible:g})".format( name = self.section_name, + detail = "{name} - {percent:.0%} ({earned:.3n}/{possible:.3n})".format( name = self.section_name, percent = percent, - earned = foundScore.earned, - possible = foundScore.possible) + earned = float(foundScore.earned), + possible = float(foundScore.possible)) else: percent = 0.0 @@ -99,6 +99,10 @@ class AssignmentFormatGrader(CourseGrader): sections in this format must be specified (even if those sections haven't been written yet). + min_number defines how many assignments are expected throughout the course. Placeholder + scores (of 0) will be inserted if the number of matching sections in the course is < min_number. + If there number of matching sections in the course is > min_number, min_number will be ignored. + 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). @@ -113,37 +117,40 @@ class AssignmentFormatGrader(CourseGrader): 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 + self.category = category or self.course_format + self.section_type = section_type or self.course_format + self.short_label = short_label or self.course_format 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:]] + dropped_indices = [] + if drop_count > 0: + 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 + + if (len(breakdown) - drop_count > 0): + aggregate_score /= len(breakdown) - drop_count return aggregate_score, dropped_indices #Figure the homework scores scores = grade_sheet.get(self.course_format, []) breakdown = [] - for i in range(12): + for i in range( max(self.min_number, len(scores)) ): 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, + summary = "{section_type} {index} - {name} - {percent:.0%} ({earned:.3n}/{possible:.3n})".format(index = i+1, section_type = self.section_type, name = scores[i].section, percent = percentage, - earned = scores[i].earned, - possible = scores[i].possible ) + earned = float(scores[i].earned), + possible = float(scores[i].possible) ) else: percentage = 0 summary = "{section_type} {index} Unreleased - 0% (?/?)".format(index = i+1, section_type = self.section_type) @@ -152,7 +159,7 @@ class AssignmentFormatGrader(CourseGrader): 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, + summary = "{section_type} {index} - {name} - {percent:.0%} ({earned:.3n}/{possible:.3n})".format(index = i+1, section_type = self.section_type, name = "Randomly Generated", percent = percentage, @@ -162,7 +169,7 @@ class AssignmentFormatGrader(CourseGrader): 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: @@ -251,23 +258,15 @@ def grade_sheet(student): 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")) ) + scores.append( Score(int(correct),total, float(p.get("weight", total)), graded, p.get("name")) ) - section_total, graded_total = aggregate_scores(scores, s) + section_total, graded_total = aggregate_scores(scores, s.get("name"), s.get("weight", 1)) #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 @@ -291,10 +290,10 @@ def grade_sheet(student): '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") + hwGrader = AssignmentFormatGrader("Homework", 12, 2, short_label = "HW") + labGrader = AssignmentFormatGrader("Lab", 12, 2, category = "Labs") + midtermGrader = SingleSectionGrader("Midterm", "Midterm Exam", short_label = "Midterm") + finalGrader = SingleSectionGrader("Examination", "Final Exam", short_label = "Final") grader = WeightedSubsectionsGrader( [(hwGrader, hwGrader.category, 0.15), (labGrader, labGrader.category, 0.15), (midtermGrader, midtermGrader.category, 0.30), (finalGrader, finalGrader.category, 0.40)] ) @@ -304,7 +303,7 @@ def grade_sheet(student): return {'courseware_summary' : chapters, 'grade_summary' : grade_summary} -def aggregate_scores(scores, section): +def aggregate_scores(scores, section_name = "summary", section_weight = 1): #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 ) @@ -313,20 +312,18 @@ def aggregate_scores(scores, section): 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")) + section_name) #selecting only graded things graded_total = Score(total_correct_graded, total_possible_graded, section_weight, True, - section.get("name")) + section_name) return all_total, graded_total diff --git a/djangoapps/courseware/tests.py b/djangoapps/courseware/tests.py index 7eb6aa27de..c672b8ba94 100644 --- a/djangoapps/courseware/tests.py +++ b/djangoapps/courseware/tests.py @@ -4,7 +4,7 @@ import numpy import courseware.modules import courseware.capa.calc as calc -from grades import Score, aggregate_scores +from grades import Score, aggregate_scores, WeightedSubsectionsGrader, SingleSectionGrader, AssignmentFormatGrader class ModelsTest(unittest.TestCase): def setUp(self): @@ -54,7 +54,7 @@ class ModelsTest(unittest.TestCase): exception_happened = True self.assertTrue(exception_happened) -class GraderTest(unittest.TestCase): +class GradesheetTest(unittest.TestCase): def test_weighted_grading(self): scores = [] @@ -93,3 +93,97 @@ class GraderTest(unittest.TestCase): all, graded = aggregate_scores(scores) self.assertAlmostEqual(all, Score(earned=14.0/5, possible=7.5, weight=1, graded=False, section="summary")) self.assertAlmostEqual(graded, Score(earned=8.0/5, possible=3.5, weight=1, graded=True, section="summary")) + +class GraderTest(unittest.TestCase): + + empty_gradesheet = { + } + + incomplete_gradesheet = { + 'Homework': [], + 'Lab': [], + 'Midterm' : [], + } + + test_gradesheet = { + 'Homework': [Score(earned=2, possible=20.0, weight=1, graded=True, section='hw1'), + Score(earned=16, possible=16.0, weight=1, graded=True, section='hw2')], + #The dropped scores should be from the assignments that don't exist yet + + 'Lab': [Score(earned=1, possible=2.0, weight=1, graded=True, section='lab1'), #Dropped + Score(earned=1, possible=1.0, weight=1, graded=True, section='lab2'), + Score(earned=1, possible=1.0, weight=1, graded=True, section='lab3'), + Score(earned=5, possible=25.0, weight=1, graded=True, section='lab4'), #Dropped + Score(earned=3, possible=4.0, weight=1, graded=True, section='lab5'), #Dropped + Score(earned=6, possible=7.0, weight=1, graded=True, section='lab6'), + Score(earned=5, possible=6.0, weight=1, graded=True, section='lab7')], + + 'Midterm' : [Score(earned=50.5, possible=100, weight=1, graded=True, section="Midterm Exam"),], + } + + def test_SingleSectionGrader(self): + midtermGrader = SingleSectionGrader("Midterm", "Midterm Exam") + lab4Grader = SingleSectionGrader("Lab", "lab4") + badLabGrader = SingleSectionGrader("Lab", "lab42") + + for graded in [midtermGrader.grade(self.empty_gradesheet), + midtermGrader.grade(self.incomplete_gradesheet), + badLabGrader.grade(self.test_gradesheet)]: + self.assertEqual( len(graded['section_breakdown']), 1 ) + self.assertEqual( graded['percent'], 0.0 ) + + graded = midtermGrader.grade(self.test_gradesheet) + self.assertAlmostEqual( graded['percent'], 0.505 ) + self.assertEqual( len(graded['section_breakdown']), 1 ) + + graded = lab4Grader.grade(self.test_gradesheet) + self.assertAlmostEqual( graded['percent'], 0.2 ) + self.assertEqual( len(graded['section_breakdown']), 1 ) + + + + + def test_assignmentFormatGrader(self): + homeworkGrader = AssignmentFormatGrader("Homework", 12, 2) + noDropGrader = AssignmentFormatGrader("Homework", 12, 0) + #Even though the minimum number is 3, this should grade correctly when 7 assignments are found + overflowGrader = AssignmentFormatGrader("Lab", 3, 2) + labGrader = AssignmentFormatGrader("Lab", 7, 3) + + + #Test the grading of an empty gradesheet + for graded in [ homeworkGrader.grade(self.empty_gradesheet), + noDropGrader.grade(self.empty_gradesheet), + homeworkGrader.grade(self.incomplete_gradesheet), + noDropGrader.grade(self.incomplete_gradesheet) ]: + self.assertAlmostEqual( graded['percent'], 0.0 ) + #Make sure the breakdown includes 12 sections, plus one summary + self.assertEqual( len(graded['section_breakdown']), 12 + 1 ) + + + graded = homeworkGrader.grade(self.test_gradesheet) + self.assertAlmostEqual( graded['percent'], 0.11 ) # 100% + 10% / 10 assignments + self.assertEqual( len(graded['section_breakdown']), 12 + 1 ) + + graded = noDropGrader.grade(self.test_gradesheet) + self.assertAlmostEqual( graded['percent'], 0.0916666666666666 ) # 100% + 10% / 12 assignments + self.assertEqual( len(graded['section_breakdown']), 12 + 1 ) + + graded = overflowGrader.grade(self.test_gradesheet) + self.assertAlmostEqual( graded['percent'], 0.8880952380952382 ) # 100% + 10% / 5 assignments + self.assertEqual( len(graded['section_breakdown']), 7 + 1 ) + + graded = labGrader.grade(self.test_gradesheet) + self.assertAlmostEqual( graded['percent'], 0.9226190476190477 ) + self.assertEqual( len(graded['section_breakdown']), 7 + 1 ) + + + + + + + + + + + From 97f4f1b7ba46c91d2af943ba2604e967ce1461a6 Mon Sep 17 00:00:00 2001 From: Bridger Maxwell Date: Sun, 15 Apr 2012 16:07:16 -0400 Subject: [PATCH 10/22] Added more tests and documentation to grade refactor. --- djangoapps/courseware/grades.py | 8 ++++-- djangoapps/courseware/tests.py | 50 +++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/djangoapps/courseware/grades.py b/djangoapps/courseware/grades.py index 53bff94c64..29c117c9fb 100644 --- a/djangoapps/courseware/grades.py +++ b/djangoapps/courseware/grades.py @@ -23,11 +23,15 @@ class CourseGrader(object): 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 + a final grade by totalling the contribution of each sub grader and multiplying it by the + given weight. 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. + + Note that the sum of the weights is not take into consideration. If the weights add up to + 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 diff --git a/djangoapps/courseware/tests.py b/djangoapps/courseware/tests.py index c672b8ba94..8759b51c79 100644 --- a/djangoapps/courseware/tests.py +++ b/djangoapps/courseware/tests.py @@ -140,6 +140,56 @@ class GraderTest(unittest.TestCase): self.assertAlmostEqual( graded['percent'], 0.2 ) self.assertEqual( len(graded['section_breakdown']), 1 ) + def test_WeightedSubsectionsGrader(self): + #First, a few sub graders + homeworkGrader = AssignmentFormatGrader("Homework", 12, 2) + labGrader = AssignmentFormatGrader("Lab", 7, 3) + midtermGrader = SingleSectionGrader("Midterm", "Midterm Exam") + + weightedGrader = WeightedSubsectionsGrader( [(homeworkGrader, homeworkGrader.category, 0.25), (labGrader, labGrader.category, 0.25), + (midtermGrader, midtermGrader.category, 0.5)] ) + + overOneWeightsGrader = WeightedSubsectionsGrader( [(homeworkGrader, homeworkGrader.category, 0.5), (labGrader, labGrader.category, 0.5), + (midtermGrader, midtermGrader.category, 0.5)] ) + + #The midterm should have all weight on this one + zeroWeightsGrader = WeightedSubsectionsGrader( [(homeworkGrader, homeworkGrader.category, 0.0), (labGrader, labGrader.category, 0.0), + (midtermGrader, midtermGrader.category, 0.5)] ) + + #This should always have a final percent of zero + allZeroWeightsGrader = WeightedSubsectionsGrader( [(homeworkGrader, homeworkGrader.category, 0.0), (labGrader, labGrader.category, 0.0), + (midtermGrader, midtermGrader.category, 0.0)] ) + + + graded = weightedGrader.grade(self.test_gradesheet) + self.assertAlmostEqual( graded['percent'], 0.5106547619047619 ) + self.assertEqual( len(graded['section_breakdown']), (12 + 1) + (7+1) + 1 ) + self.assertEqual( len(graded['grade_breakdown']), 3 ) + + graded = overOneWeightsGrader.grade(self.test_gradesheet) + self.assertAlmostEqual( graded['percent'], 0.7688095238095238 ) + self.assertEqual( len(graded['section_breakdown']), (12 + 1) + (7+1) + 1 ) + self.assertEqual( len(graded['grade_breakdown']), 3 ) + + graded = zeroWeightsGrader.grade(self.test_gradesheet) + self.assertAlmostEqual( graded['percent'], 0.2525 ) + self.assertEqual( len(graded['section_breakdown']), (12 + 1) + (7+1) + 1 ) + self.assertEqual( len(graded['grade_breakdown']), 3 ) + + + graded = allZeroWeightsGrader.grade(self.test_gradesheet) + self.assertAlmostEqual( graded['percent'], 0.0 ) + self.assertEqual( len(graded['section_breakdown']), (12 + 1) + (7+1) + 1 ) + self.assertEqual( len(graded['grade_breakdown']), 3 ) + + for graded in [ weightedGrader.grade(self.empty_gradesheet), + weightedGrader.grade(self.incomplete_gradesheet), + zeroWeightsGrader.grade(self.empty_gradesheet), + allZeroWeightsGrader.grade(self.empty_gradesheet)]: + self.assertAlmostEqual( graded['percent'], 0.0 ) + #section_breakdown should have all subsections from before + self.assertEqual( len(graded['section_breakdown']), (12 + 1) + (7+1) + 1 ) + self.assertEqual( len(graded['grade_breakdown']), 3 ) From 02e92e0b317157a342b27f2d4850050745e86cc2 Mon Sep 17 00:00:00 2001 From: Bridger Maxwell Date: Sun, 15 Apr 2012 16:48:06 -0400 Subject: [PATCH 11/22] Added comments explaining the grader protocol. --- djangoapps/courseware/grades.py | 39 +++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/djangoapps/courseware/grades.py b/djangoapps/courseware/grades.py index 29c117c9fb..6d91654bb0 100644 --- a/djangoapps/courseware/grades.py +++ b/djangoapps/courseware/grades.py @@ -17,6 +17,45 @@ SectionPercentage = namedtuple("SectionPercentage", "percentage label summary") class CourseGrader(object): + """ + A course grader takes the totaled scores for each graded section (that a student has + started) in the course. From these scores, the grader calculates an overall percentage + grade. The grader should also generate information about how that score was calculated, + to be displayed in graphs or charts. + + A grader has one required method, grade(), which is passed a grade_sheet. The grade_sheet + contains scores for all graded section that the student has started. If a student has + a score of 0 for that section, it may be missing from the grade_sheet. The grade_sheet + is keyed by section format. Each value is a list of Score namedtuples for each section + that has the matching section format. + + The grader outputs a dictionary with the following keys: + - percent: Contaisn a float value, which is the final percentage score for the student. + - 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. + + A dictionary in the section_breakdown list has the following keys: + percent: A float percentage for the section. + label: A short string identifying the section. Preferably fixed-length. E.g. "HW 3". + detail: A string explanation of the score. E.g. "Homework 1 - Ohms Law - 83% (5/6)" + category: A string identifying the category. Items with the same category are grouped together + in the display (for example, by color). + 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: + 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 + in the display (for example, by color). + + + """ def grade(self, grade_sheet): raise NotImplementedError From 795fba0448bb6b8863dd860aae7c3cd5c811e881 Mon Sep 17 00:00:00 2001 From: Bridger Maxwell Date: Sun, 15 Apr 2012 17:05:07 -0400 Subject: [PATCH 12/22] Got settings.GENERATE_RANDOM_PROFILE_SCORES working again. Mostly used for design previews. --- djangoapps/courseware/grades.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/djangoapps/courseware/grades.py b/djangoapps/courseware/grades.py index 6d91654bb0..0daa0a8940 100644 --- a/djangoapps/courseware/grades.py +++ b/djangoapps/courseware/grades.py @@ -117,7 +117,7 @@ class SingleSectionGrader(CourseGrader): break if foundScore: - percent = foundScore.earned / float(foundScore.possible) + percent = foundScore.earned / float(foundScore.possible) detail = "{name} - {percent:.0%} ({earned:.3n}/{possible:.3n})".format( name = self.section_name, percent = percent, earned = float(foundScore.earned), @@ -127,6 +127,17 @@ class SingleSectionGrader(CourseGrader): percent = 0.0 detail = "{name} - 0% (?/?)".format(name = self.section_name) + if settings.GENERATE_PROFILE_SCORES: + points_possible = random.randrange(50, 100) + points_earned = random.randrange(40, points_possible) + percent = points_earned / float(points_possible) + detail = "{name} - {percent:.0%} ({earned:.3n}/{possible:.3n})".format( name = self.section_name, + percent = percent, + earned = float(points_earned), + possible = float(points_possible)) + + + breakdown = [{'percent': percent, 'label': self.short_label, 'detail': detail, 'category': self.category, 'prominent': True}] @@ -206,8 +217,8 @@ class AssignmentFormatGrader(CourseGrader): section_type = self.section_type, name = "Randomly Generated", percent = percentage, - earned = points_earned, - possible = points_possible ) + earned = float(points_earned), + possible = float(points_possible) ) short_label = "{short_label} {index:02d}".format(index = i+1, short_label = self.short_label) From 3860f333d6a79492074b983590e8ddb8b7922b3e Mon Sep 17 00:00:00 2001 From: Bridger Maxwell Date: Mon, 16 Apr 2012 16:13:51 -0400 Subject: [PATCH 13/22] Added parser for creating graders from a dictionary representation. --- djangoapps/courseware/grades.py | 42 +++++++-- djangoapps/courseware/tests.py | 155 +++++++++++++++++++++----------- 2 files changed, 137 insertions(+), 60 deletions(-) diff --git a/djangoapps/courseware/grades.py b/djangoapps/courseware/grades.py index 0daa0a8940..cf675a7b02 100644 --- a/djangoapps/courseware/grades.py +++ b/djangoapps/courseware/grades.py @@ -59,6 +59,36 @@ class CourseGrader(object): def grade(self, grade_sheet): raise NotImplementedError + + @classmethod + def graderFromConf(cls, conf): + if isinstance(conf, CourseGrader): + return conf + + subgraders = [] + for subgraderconf in conf: + subgraderconf = subgraderconf.copy() + weight = subgraderconf.pop("weight", 0) + try: + if 'min_count' in subgraderconf: + #This is an AssignmentFormatGrader + subgrader = AssignmentFormatGrader(**subgraderconf) + subgraders.append( (subgrader, subgrader.category, weight) ) + elif 'section_name' in subgraderconf: + #This is an SingleSectionGrader + subgrader = SingleSectionGrader(**subgraderconf) + subgraders.append( (subgrader, subgrader.category, weight) ) + else: + raise ValueError("Configuration has no appropriate grader class.") + + except TypeError as error: + log.error("Unable to parse grader configuration:\n" + subgraderconf + "\nError was:\n" + error) + except ValueError as error: + log.error("Unable to parse grader configuration:\n" + subgraderconf + "\nError was:\n" + error) + + return WeightedSubsectionsGrader( subgraders ) + + class WeightedSubsectionsGrader(CourseGrader): """ This grader takes a list of tuples containing (grader, category_name, weight) and computes @@ -153,9 +183,9 @@ class AssignmentFormatGrader(CourseGrader): sections in this format must be specified (even if those sections haven't been written yet). - min_number defines how many assignments are expected throughout the course. Placeholder - scores (of 0) will be inserted if the number of matching sections in the course is < min_number. - If there number of matching sections in the course is > min_number, min_number will be ignored. + min_count defines how many assignments are expected throughout the course. Placeholder + scores (of 0) will be inserted if the number of matching sections in the course is < min_count. + If there number of matching sections in the course is > min_count, min_count will be ignored. 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). @@ -167,9 +197,9 @@ class AssignmentFormatGrader(CourseGrader): "HW". """ - def __init__(self, course_format, min_number, drop_count, category = None, section_type = None, short_label = None): + def __init__(self, course_format, min_count, drop_count, category = None, section_type = None, short_label = None): self.course_format = course_format - self.min_number = min_number + self.min_count = min_count self.drop_count = drop_count self.category = category or self.course_format self.section_type = section_type or self.course_format @@ -196,7 +226,7 @@ class AssignmentFormatGrader(CourseGrader): #Figure the homework scores scores = grade_sheet.get(self.course_format, []) breakdown = [] - for i in range( max(self.min_number, len(scores)) ): + for i in range( max(self.min_count, len(scores)) ): if i < len(scores): percentage = scores[i].earned / float(scores[i].possible) summary = "{section_type} {index} - {name} - {percent:.0%} ({earned:.3n}/{possible:.3n})".format(index = i+1, diff --git a/djangoapps/courseware/tests.py b/djangoapps/courseware/tests.py index 8759b51c79..1785d348d8 100644 --- a/djangoapps/courseware/tests.py +++ b/djangoapps/courseware/tests.py @@ -4,7 +4,7 @@ import numpy import courseware.modules import courseware.capa.calc as calc -from grades import Score, aggregate_scores, WeightedSubsectionsGrader, SingleSectionGrader, AssignmentFormatGrader +from grades import Score, aggregate_scores, CourseGrader, WeightedSubsectionsGrader, SingleSectionGrader, AssignmentFormatGrader class ModelsTest(unittest.TestCase): def setUp(self): @@ -140,59 +140,6 @@ class GraderTest(unittest.TestCase): self.assertAlmostEqual( graded['percent'], 0.2 ) self.assertEqual( len(graded['section_breakdown']), 1 ) - def test_WeightedSubsectionsGrader(self): - #First, a few sub graders - homeworkGrader = AssignmentFormatGrader("Homework", 12, 2) - labGrader = AssignmentFormatGrader("Lab", 7, 3) - midtermGrader = SingleSectionGrader("Midterm", "Midterm Exam") - - weightedGrader = WeightedSubsectionsGrader( [(homeworkGrader, homeworkGrader.category, 0.25), (labGrader, labGrader.category, 0.25), - (midtermGrader, midtermGrader.category, 0.5)] ) - - overOneWeightsGrader = WeightedSubsectionsGrader( [(homeworkGrader, homeworkGrader.category, 0.5), (labGrader, labGrader.category, 0.5), - (midtermGrader, midtermGrader.category, 0.5)] ) - - #The midterm should have all weight on this one - zeroWeightsGrader = WeightedSubsectionsGrader( [(homeworkGrader, homeworkGrader.category, 0.0), (labGrader, labGrader.category, 0.0), - (midtermGrader, midtermGrader.category, 0.5)] ) - - #This should always have a final percent of zero - allZeroWeightsGrader = WeightedSubsectionsGrader( [(homeworkGrader, homeworkGrader.category, 0.0), (labGrader, labGrader.category, 0.0), - (midtermGrader, midtermGrader.category, 0.0)] ) - - - graded = weightedGrader.grade(self.test_gradesheet) - self.assertAlmostEqual( graded['percent'], 0.5106547619047619 ) - self.assertEqual( len(graded['section_breakdown']), (12 + 1) + (7+1) + 1 ) - self.assertEqual( len(graded['grade_breakdown']), 3 ) - - graded = overOneWeightsGrader.grade(self.test_gradesheet) - self.assertAlmostEqual( graded['percent'], 0.7688095238095238 ) - self.assertEqual( len(graded['section_breakdown']), (12 + 1) + (7+1) + 1 ) - self.assertEqual( len(graded['grade_breakdown']), 3 ) - - graded = zeroWeightsGrader.grade(self.test_gradesheet) - self.assertAlmostEqual( graded['percent'], 0.2525 ) - self.assertEqual( len(graded['section_breakdown']), (12 + 1) + (7+1) + 1 ) - self.assertEqual( len(graded['grade_breakdown']), 3 ) - - - graded = allZeroWeightsGrader.grade(self.test_gradesheet) - self.assertAlmostEqual( graded['percent'], 0.0 ) - self.assertEqual( len(graded['section_breakdown']), (12 + 1) + (7+1) + 1 ) - self.assertEqual( len(graded['grade_breakdown']), 3 ) - - for graded in [ weightedGrader.grade(self.empty_gradesheet), - weightedGrader.grade(self.incomplete_gradesheet), - zeroWeightsGrader.grade(self.empty_gradesheet), - allZeroWeightsGrader.grade(self.empty_gradesheet)]: - self.assertAlmostEqual( graded['percent'], 0.0 ) - #section_breakdown should have all subsections from before - self.assertEqual( len(graded['section_breakdown']), (12 + 1) + (7+1) + 1 ) - self.assertEqual( len(graded['grade_breakdown']), 3 ) - - - def test_assignmentFormatGrader(self): homeworkGrader = AssignmentFormatGrader("Homework", 12, 2) noDropGrader = AssignmentFormatGrader("Homework", 12, 0) @@ -228,12 +175,112 @@ class GraderTest(unittest.TestCase): self.assertEqual( len(graded['section_breakdown']), 7 + 1 ) + def test_WeightedSubsectionsGrader(self): + #First, a few sub graders + homeworkGrader = AssignmentFormatGrader("Homework", 12, 2) + labGrader = AssignmentFormatGrader("Lab", 7, 3) + midtermGrader = SingleSectionGrader("Midterm", "Midterm Exam") + + weightedGrader = WeightedSubsectionsGrader( [(homeworkGrader, homeworkGrader.category, 0.25), (labGrader, labGrader.category, 0.25), + (midtermGrader, midtermGrader.category, 0.5)] ) + + overOneWeightsGrader = WeightedSubsectionsGrader( [(homeworkGrader, homeworkGrader.category, 0.5), (labGrader, labGrader.category, 0.5), + (midtermGrader, midtermGrader.category, 0.5)] ) + + #The midterm should have all weight on this one + zeroWeightsGrader = WeightedSubsectionsGrader( [(homeworkGrader, homeworkGrader.category, 0.0), (labGrader, labGrader.category, 0.0), + (midtermGrader, midtermGrader.category, 0.5)] ) + + #This should always have a final percent of zero + allZeroWeightsGrader = WeightedSubsectionsGrader( [(homeworkGrader, homeworkGrader.category, 0.0), (labGrader, labGrader.category, 0.0), + (midtermGrader, midtermGrader.category, 0.0)] ) + + emptyGrader = WeightedSubsectionsGrader( [] ) + + graded = weightedGrader.grade(self.test_gradesheet) + self.assertAlmostEqual( graded['percent'], 0.5106547619047619 ) + self.assertEqual( len(graded['section_breakdown']), (12 + 1) + (7+1) + 1 ) + self.assertEqual( len(graded['grade_breakdown']), 3 ) + + graded = overOneWeightsGrader.grade(self.test_gradesheet) + self.assertAlmostEqual( graded['percent'], 0.7688095238095238 ) + self.assertEqual( len(graded['section_breakdown']), (12 + 1) + (7+1) + 1 ) + self.assertEqual( len(graded['grade_breakdown']), 3 ) + + graded = zeroWeightsGrader.grade(self.test_gradesheet) + self.assertAlmostEqual( graded['percent'], 0.2525 ) + self.assertEqual( len(graded['section_breakdown']), (12 + 1) + (7+1) + 1 ) + self.assertEqual( len(graded['grade_breakdown']), 3 ) + + + graded = allZeroWeightsGrader.grade(self.test_gradesheet) + self.assertAlmostEqual( graded['percent'], 0.0 ) + self.assertEqual( len(graded['section_breakdown']), (12 + 1) + (7+1) + 1 ) + self.assertEqual( len(graded['grade_breakdown']), 3 ) + + for graded in [ weightedGrader.grade(self.empty_gradesheet), + weightedGrader.grade(self.incomplete_gradesheet), + zeroWeightsGrader.grade(self.empty_gradesheet), + allZeroWeightsGrader.grade(self.empty_gradesheet)]: + self.assertAlmostEqual( graded['percent'], 0.0 ) + self.assertEqual( len(graded['section_breakdown']), (12 + 1) + (7+1) + 1 ) + self.assertEqual( len(graded['grade_breakdown']), 3 ) + + + graded = emptyGrader.grade(self.test_gradesheet) + self.assertAlmostEqual( graded['percent'], 0.0 ) + self.assertEqual( len(graded['section_breakdown']), 0 ) + self.assertEqual( len(graded['grade_breakdown']), 0 ) + + + def test_graderFromConf(self): + #Confs always produce a WeightedSubsectionsGrader, so we test this by repeating the test + #in test_WeightedSubsectionsGrader, but generate the graders with confs. + weightedGrader = CourseGrader.graderFromConf([ + { + 'course_format' : "Homework", + 'min_count' : 12, + 'drop_count' : 2, + 'short_label' : "HW", + 'weight' : 0.25, + }, + { + 'course_format' : "Lab", + 'min_count' : 7, + 'drop_count' : 3, + 'category' : "Labs", + 'weight' : 0.25 + }, + { + 'section_format' : "Midterm", + 'section_name' : "Midterm Exam", + 'short_label' : "Midterm", + 'weight' : 0.5, + }, + ]) + emptyGrader = CourseGrader.graderFromConf([]) + graded = weightedGrader.grade(self.test_gradesheet) + self.assertAlmostEqual( graded['percent'], 0.5106547619047619 ) + self.assertEqual( len(graded['section_breakdown']), (12 + 1) + (7+1) + 1 ) + self.assertEqual( len(graded['grade_breakdown']), 3 ) + graded = emptyGrader.grade(self.test_gradesheet) + self.assertAlmostEqual( graded['percent'], 0.0 ) + self.assertEqual( len(graded['section_breakdown']), 0 ) + self.assertEqual( len(graded['grade_breakdown']), 0 ) + #Test that graders can also be used instead of lists of dictionaries + homeworkGrader = AssignmentFormatGrader("Homework", 12, 2) + homeworkGrader2 = CourseGrader.graderFromConf(homeworkGrader) + graded = homeworkGrader2.grade(self.test_gradesheet) + 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? From e3b397870f0c7d73a6023d385e53bdb37fbaf6a1 Mon Sep 17 00:00:00 2001 From: Bridger Maxwell Date: Mon, 16 Apr 2012 16:57:24 -0400 Subject: [PATCH 14/22] Moved the weighting of problems to when problem scores are retrieved. --- djangoapps/courseware/grades.py | 38 ++++++++++++------------ djangoapps/courseware/tests.py | 52 ++++++++++++++++----------------- templates/profile.html | 4 +-- 3 files changed, 48 insertions(+), 46 deletions(-) diff --git a/djangoapps/courseware/grades.py b/djangoapps/courseware/grades.py index cf675a7b02..359fe91588 100644 --- a/djangoapps/courseware/grades.py +++ b/djangoapps/courseware/grades.py @@ -12,7 +12,7 @@ from student.models import UserProfile log = logging.getLogger("mitx.courseware") -Score = namedtuple("Score", "earned possible weight graded section") +Score = namedtuple("Score", "earned possible graded section") SectionPercentage = namedtuple("SectionPercentage", "percentage label summary") @@ -273,7 +273,7 @@ class AssignmentFormatGrader(CourseGrader): def get_score(user, problem, cache): ## HACK: assumes max score is fixed per problem id = problem.get('id') - correct = 0 + correct = 0.0 # If the ID is not in the cache, add the item if id not in cache: @@ -290,15 +290,22 @@ def get_score(user, problem, cache): if id in cache: response = cache[id] if response.grade!=None: - correct=response.grade + correct=float(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() + total=float(courseware.modules.capa_module.Module(etree.tostring(problem), "id").max_score()) response.max_grade = total response.save() + + #Now we re-weight the problem, if specified + weight = problem.get("weight", None) + if weight: + weight = float(weight) + correct = correct * weight / total + total = weight return (correct, total) @@ -348,12 +355,12 @@ def grade_sheet(student): correct = random.randrange( max(total-2, 1) , total + 1 ) else: correct = total - scores.append( Score(int(correct),total, float(p.get("weight", total)), graded, p.get("name")) ) + scores.append( Score(correct,total, graded, p.get("name")) ) - section_total, graded_total = aggregate_scores(scores, s.get("name"), s.get("weight", 1)) + section_total, graded_total = aggregate_scores(scores, s.get("name")) #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 + format = s.get('format', "") + subtitle = s.get('subtitle', format) if format and graded_total[1] > 0: format_scores = totaled_scores.get(format, []) format_scores.append( graded_total ) @@ -387,26 +394,21 @@ def grade_sheet(student): return {'courseware_summary' : chapters, 'grade_summary' : grade_summary} -def aggregate_scores(scores, section_name = "summary", section_weight = 1): - #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 ) +def aggregate_scores(scores, section_name = "summary"): + total_correct_graded = sum(score.earned for score in scores if score.graded) + total_possible_graded = sum(score.possible for score in scores if score.graded) - 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) + total_correct = sum(score.earned for score in scores) + total_possible = sum(score.possible for score in scores) #regardless of whether or not it is graded all_total = Score(total_correct, total_possible, - section_weight, False, section_name) #selecting only graded things graded_total = Score(total_correct_graded, total_possible_graded, - section_weight, True, section_name) diff --git a/djangoapps/courseware/tests.py b/djangoapps/courseware/tests.py index 1785d348d8..7d02b45264 100644 --- a/djangoapps/courseware/tests.py +++ b/djangoapps/courseware/tests.py @@ -61,38 +61,38 @@ class GradesheetTest(unittest.TestCase): Score.__sub__=lambda me, other: (me.earned - other.earned) + (me.possible - other.possible) all, graded = aggregate_scores(scores) - self.assertEqual(all, Score(earned=0, possible=0, weight=1, graded=False, section="summary")) - self.assertEqual(graded, Score(earned=0, possible=0, weight=1, graded=True, section="summary")) + self.assertEqual(all, Score(earned=0, possible=0, graded=False, section="summary")) + self.assertEqual(graded, Score(earned=0, possible=0, graded=True, section="summary")) - scores.append(Score(earned=0, possible=5, weight=1, graded=False, section="summary")) + scores.append(Score(earned=0, possible=5, graded=False, section="summary")) all, graded = aggregate_scores(scores) - self.assertEqual(all, Score(earned=0, possible=1, weight=1, graded=False, section="summary")) - self.assertEqual(graded, Score(earned=0, possible=0, weight=1, graded=True, section="summary")) + self.assertEqual(all, Score(earned=0, possible=1, graded=False, section="summary")) + self.assertEqual(graded, Score(earned=0, possible=0, graded=True, section="summary")) - scores.append(Score(earned=3, possible=5, weight=1, graded=True, section="summary")) + scores.append(Score(earned=3, possible=5, graded=True, section="summary")) all, graded = aggregate_scores(scores) - self.assertAlmostEqual(all, Score(earned=3.0/5, possible=2, weight=1, graded=False, section="summary")) - self.assertAlmostEqual(graded, Score(earned=3.0/5, possible=1, weight=1, graded=True, section="summary")) + self.assertAlmostEqual(all, Score(earned=3.0/5, possible=2, graded=False, section="summary")) + self.assertAlmostEqual(graded, Score(earned=3.0/5, possible=1, graded=True, section="summary")) scores.append(Score(earned=2, possible=5, weight=2, graded=True, section="summary")) all, graded = aggregate_scores(scores) - self.assertAlmostEqual(all, Score(earned=7.0/5, possible=4, weight=1, graded=False, section="summary")) - self.assertAlmostEqual(graded, Score(earned=7.0/5, possible=3, weight=1, graded=True, section="summary")) + self.assertAlmostEqual(all, Score(earned=7.0/5, possible=4, graded=False, section="summary")) + self.assertAlmostEqual(graded, Score(earned=7.0/5, possible=3, graded=True, section="summary")) scores.append(Score(earned=2, possible=5, weight=0, graded=True, section="summary")) all, graded = aggregate_scores(scores) - self.assertAlmostEqual(all, Score(earned=7.0/5, possible=4, weight=1, graded=False, section="summary")) - self.assertAlmostEqual(graded, Score(earned=7.0/5, possible=3, weight=1, graded=True, section="summary")) + self.assertAlmostEqual(all, Score(earned=7.0/5, possible=4, graded=False, section="summary")) + self.assertAlmostEqual(graded, Score(earned=7.0/5, possible=3, graded=True, section="summary")) scores.append(Score(earned=2, possible=5, weight=3, graded=False, section="summary")) all, graded = aggregate_scores(scores) - self.assertAlmostEqual(all, Score(earned=13.0/5, possible=7, weight=1, graded=False, section="summary")) - self.assertAlmostEqual(graded, Score(earned=7.0/5, possible=3, weight=1, graded=True, section="summary")) + self.assertAlmostEqual(all, Score(earned=13.0/5, possible=7, graded=False, section="summary")) + self.assertAlmostEqual(graded, Score(earned=7.0/5, possible=3, graded=True, section="summary")) scores.append(Score(earned=2, possible=5, weight=.5, graded=True, section="summary")) all, graded = aggregate_scores(scores) - self.assertAlmostEqual(all, Score(earned=14.0/5, possible=7.5, weight=1, graded=False, section="summary")) - self.assertAlmostEqual(graded, Score(earned=8.0/5, possible=3.5, weight=1, graded=True, section="summary")) + self.assertAlmostEqual(all, Score(earned=14.0/5, possible=7.5, graded=False, section="summary")) + self.assertAlmostEqual(graded, Score(earned=8.0/5, possible=3.5, graded=True, section="summary")) class GraderTest(unittest.TestCase): @@ -106,19 +106,19 @@ class GraderTest(unittest.TestCase): } test_gradesheet = { - 'Homework': [Score(earned=2, possible=20.0, weight=1, graded=True, section='hw1'), - Score(earned=16, possible=16.0, weight=1, graded=True, section='hw2')], + 'Homework': [Score(earned=2, possible=20.0, graded=True, section='hw1'), + Score(earned=16, possible=16.0, graded=True, section='hw2')], #The dropped scores should be from the assignments that don't exist yet - 'Lab': [Score(earned=1, possible=2.0, weight=1, graded=True, section='lab1'), #Dropped - Score(earned=1, possible=1.0, weight=1, graded=True, section='lab2'), - Score(earned=1, possible=1.0, weight=1, graded=True, section='lab3'), - Score(earned=5, possible=25.0, weight=1, graded=True, section='lab4'), #Dropped - Score(earned=3, possible=4.0, weight=1, graded=True, section='lab5'), #Dropped - Score(earned=6, possible=7.0, weight=1, graded=True, section='lab6'), - Score(earned=5, possible=6.0, weight=1, graded=True, section='lab7')], + 'Lab': [Score(earned=1, possible=2.0, graded=True, section='lab1'), #Dropped + Score(earned=1, possible=1.0, graded=True, section='lab2'), + Score(earned=1, possible=1.0, graded=True, section='lab3'), + Score(earned=5, possible=25.0, graded=True, section='lab4'), #Dropped + Score(earned=3, possible=4.0, graded=True, section='lab5'), #Dropped + Score(earned=6, possible=7.0, graded=True, section='lab6'), + Score(earned=5, possible=6.0, graded=True, section='lab7')], - 'Midterm' : [Score(earned=50.5, possible=100, weight=1, graded=True, section="Midterm Exam"),], + 'Midterm' : [Score(earned=50.5, possible=100, graded=True, section="Midterm Exam"),], } def test_SingleSectionGrader(self): diff --git a/templates/profile.html b/templates/profile.html index 9e2e880556..a43b4e04c8 100644 --- a/templates/profile.html +++ b/templates/profile.html @@ -154,7 +154,7 @@ $(function() { %>

- ${ section['section'] } ${"({0:g}/{1:g}) {2}".format( earned, total, percentageString )}

+ ${ section['section'] } ${"({0:.3n}/{1:.3n}) {2}".format( float(earned), float(total), percentageString )}

${section['subtitle']} %if 'due' in section and section['due']!="": due ${section['due']} @@ -164,7 +164,7 @@ $(function() {
    ${ "Problem Scores: " if section['graded'] else "Practice Scores: "} %for score in section['scores']: -
  1. ${"{0:g}/{1:g}".format(score.earned,score.possible)}
  2. +
  3. ${"{0:.3n}/{1:.3n}".format(float(score.earned),float(score.possible))}
  4. %endfor
%endif From 79ffd72521671247ab1fadcb6876d1c604e5c340 Mon Sep 17 00:00:00 2001 From: Bridger Maxwell Date: Mon, 16 Apr 2012 17:28:18 -0400 Subject: [PATCH 15/22] Updated tests for new weighting method. --- djangoapps/courseware/grades.py | 1 - djangoapps/courseware/tests.py | 27 ++++++--------------------- 2 files changed, 6 insertions(+), 22 deletions(-) diff --git a/djangoapps/courseware/grades.py b/djangoapps/courseware/grades.py index 359fe91588..efa9ee9d48 100644 --- a/djangoapps/courseware/grades.py +++ b/djangoapps/courseware/grades.py @@ -331,7 +331,6 @@ def grade_sheet(student): response_by_id[response.module_id] = response - totaled_scores = {} chapters=[] for c in xmlChapters: diff --git a/djangoapps/courseware/tests.py b/djangoapps/courseware/tests.py index 7d02b45264..529d7bb473 100644 --- a/djangoapps/courseware/tests.py +++ b/djangoapps/courseware/tests.py @@ -66,33 +66,18 @@ class GradesheetTest(unittest.TestCase): scores.append(Score(earned=0, possible=5, graded=False, section="summary")) all, graded = aggregate_scores(scores) - self.assertEqual(all, Score(earned=0, possible=1, graded=False, section="summary")) + self.assertEqual(all, Score(earned=0, possible=5, graded=False, section="summary")) self.assertEqual(graded, Score(earned=0, possible=0, graded=True, section="summary")) scores.append(Score(earned=3, possible=5, graded=True, section="summary")) all, graded = aggregate_scores(scores) - self.assertAlmostEqual(all, Score(earned=3.0/5, possible=2, graded=False, section="summary")) - self.assertAlmostEqual(graded, Score(earned=3.0/5, possible=1, graded=True, section="summary")) + self.assertAlmostEqual(all, Score(earned=3, possible=10, graded=False, section="summary")) + self.assertAlmostEqual(graded, Score(earned=3, possible=5, graded=True, section="summary")) - scores.append(Score(earned=2, possible=5, weight=2, graded=True, section="summary")) + scores.append(Score(earned=2, possible=5, graded=True, section="summary")) all, graded = aggregate_scores(scores) - self.assertAlmostEqual(all, Score(earned=7.0/5, possible=4, graded=False, section="summary")) - self.assertAlmostEqual(graded, Score(earned=7.0/5, possible=3, graded=True, section="summary")) - - scores.append(Score(earned=2, possible=5, weight=0, graded=True, section="summary")) - all, graded = aggregate_scores(scores) - self.assertAlmostEqual(all, Score(earned=7.0/5, possible=4, graded=False, section="summary")) - self.assertAlmostEqual(graded, Score(earned=7.0/5, possible=3, graded=True, section="summary")) - - scores.append(Score(earned=2, possible=5, weight=3, graded=False, section="summary")) - all, graded = aggregate_scores(scores) - self.assertAlmostEqual(all, Score(earned=13.0/5, possible=7, graded=False, section="summary")) - self.assertAlmostEqual(graded, Score(earned=7.0/5, possible=3, graded=True, section="summary")) - - scores.append(Score(earned=2, possible=5, weight=.5, graded=True, section="summary")) - all, graded = aggregate_scores(scores) - self.assertAlmostEqual(all, Score(earned=14.0/5, possible=7.5, graded=False, section="summary")) - self.assertAlmostEqual(graded, Score(earned=8.0/5, possible=3.5, graded=True, section="summary")) + self.assertAlmostEqual(all, Score(earned=5, possible=15, graded=False, section="summary")) + self.assertAlmostEqual(graded, Score(earned=5, possible=10, graded=True, section="summary")) class GraderTest(unittest.TestCase): From 7b915cd12305090825c60acb4186757290ae7a1d Mon Sep 17 00:00:00 2001 From: Bridger Maxwell Date: Mon, 16 Apr 2012 17:45:53 -0400 Subject: [PATCH 16/22] Profile graph tooltips are a little more offset, so they don't appear under the mouse and flicker. --- templates/profile_graphs.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/profile_graphs.js b/templates/profile_graphs.js index 0921b1b516..58dbeb8ed9 100644 --- a/templates/profile_graphs.js +++ b/templates/profile_graphs.js @@ -9,7 +9,7 @@ $(function () { position: 'absolute', display: 'none', top: y + 5, - left: x + 5, + left: x + 15, border: '1px solid #000', padding: '4px 6px', color: '#fff', From fcb530f1e3dc5161e78c18710b2ef8ba76c810bf Mon Sep 17 00:00:00 2001 From: Bridger Maxwell Date: Mon, 16 Apr 2012 22:10:08 -0400 Subject: [PATCH 17/22] Course settings are now defined in DATA_DIR/course_settings.py. Used by importing courseware.course_settings. The grader is defined in these settings files. --- djangoapps/courseware/__init__.py | 57 +++++++++++++++++++ .../courseware/global_course_settings.py | 28 +++++++++ djangoapps/courseware/grades.py | 17 ++---- 3 files changed, 90 insertions(+), 12 deletions(-) create mode 100644 djangoapps/courseware/global_course_settings.py diff --git a/djangoapps/courseware/__init__.py b/djangoapps/courseware/__init__.py index e69de29bb2..97b279cda0 100644 --- a/djangoapps/courseware/__init__.py +++ b/djangoapps/courseware/__init__.py @@ -0,0 +1,57 @@ +""" +Course settings module. The settings are based of django.conf. All settings in +courseware.global_course_settings are first applied, and then any settings +in the settings.DATA_DIR/course_settings.py are applied. A setting must be +in ALL_CAPS. + +Settings are used by calling + +from courseware import course_settings + +Note that courseware.course_settings is not a module -- it's an object. So +importing individual settings is not possible: + +from courseware.course_settings import GRADER # This won't work. + +""" + + +import courseware +import imp +import logging +import sys +import types + +from django.conf import settings +from django.utils.functional import SimpleLazyObject +from courseware import global_course_settings + +_log = logging.getLogger("mitx.courseware") + + + +class Settings(object): + def __init__(self): + # update this dict from global settings (but only for ALL_CAPS settings) + for setting in dir(global_course_settings): + if setting == setting.upper(): + setattr(self, setting, getattr(global_course_settings, setting)) + + fp = None + try: + fp, pathname, description = imp.find_module("course_settings", [settings.DATA_DIR]) + mod = imp.load_module("course_settings", fp, pathname, description) + except Exception as e: + _log.error("Unable to import course settings file from " + settings.DATA_DIR + ". Error: " + str(e)) + mod = types.ModuleType('course_settings') + finally: + if fp: + fp.close() + + for setting in dir(mod): + if setting == setting.upper(): + setting_value = getattr(mod, setting) + setattr(self, setting, setting_value) + + +course_settings = Settings() \ No newline at end of file diff --git a/djangoapps/courseware/global_course_settings.py b/djangoapps/courseware/global_course_settings.py new file mode 100644 index 0000000000..b75ff6ee78 --- /dev/null +++ b/djangoapps/courseware/global_course_settings.py @@ -0,0 +1,28 @@ +GRADER = [ + { + 'course_format' : "Homework", + 'min_count' : 12, + 'drop_count' : 2, + 'short_label' : "HW", + 'weight' : 0.15, + }, + { + 'course_format' : "Lab", + 'min_count' : 12, + 'drop_count' : 2, + 'category' : "Labs", + 'weight' : 0.15 + }, + { + 'section_format' : "Examination", + 'section_name' : "Midterm Exam", + 'short_label' : "Midterm", + 'weight' : 0.3, + }, + { + 'section_format' : "Examination", + 'section_name' : "Final Exam", + 'short_label' : "Final", + 'weight' : 0.4, + } +] diff --git a/djangoapps/courseware/grades.py b/djangoapps/courseware/grades.py index efa9ee9d48..af33448b6c 100644 --- a/djangoapps/courseware/grades.py +++ b/djangoapps/courseware/grades.py @@ -5,6 +5,7 @@ import random import urllib from collections import namedtuple +from courseware import course_settings from django.conf import settings from lxml import etree from models import StudentModule @@ -317,9 +318,7 @@ def grade_sheet(student): 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. + - grade_summary is the output from the course grader. More information on the format is in the docstring for CourseGrader. """ dom=content_parser.course_file(student) course = dom.xpath('//course/@name')[0] @@ -379,15 +378,9 @@ def grade_sheet(student): '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, short_label = "HW") - labGrader = AssignmentFormatGrader("Lab", 12, 2, category = "Labs") - midtermGrader = SingleSectionGrader("Midterm", "Midterm Exam", short_label = "Midterm") - finalGrader = SingleSectionGrader("Examination", "Final Exam", short_label = "Final") - - grader = WeightedSubsectionsGrader( [(hwGrader, hwGrader.category, 0.15), (labGrader, labGrader.category, 0.15), - (midtermGrader, midtermGrader.category, 0.30), (finalGrader, finalGrader.category, 0.40)] ) - + + grader = CourseGrader.graderFromConf(course_settings.GRADER) + #TODO: We should cache this grader object grade_summary = grader.grade(totaled_scores) return {'courseware_summary' : chapters, From a763edab40a549f078b9b8aee2f9339387cdaf5a Mon Sep 17 00:00:00 2001 From: Bridger Maxwell Date: Mon, 16 Apr 2012 22:15:58 -0400 Subject: [PATCH 18/22] Just a bit of cleanup. No code changes --- djangoapps/courseware/__init__.py | 4 -- djangoapps/courseware/grades.py | 80 +++++++++++++++---------------- 2 files changed, 40 insertions(+), 44 deletions(-) diff --git a/djangoapps/courseware/__init__.py b/djangoapps/courseware/__init__.py index 97b279cda0..b8782ab30c 100644 --- a/djangoapps/courseware/__init__.py +++ b/djangoapps/courseware/__init__.py @@ -15,7 +15,6 @@ from courseware.course_settings import GRADER # This won't work. """ - import courseware import imp import logging @@ -28,8 +27,6 @@ from courseware import global_course_settings _log = logging.getLogger("mitx.courseware") - - class Settings(object): def __init__(self): # update this dict from global settings (but only for ALL_CAPS settings) @@ -53,5 +50,4 @@ class Settings(object): setting_value = getattr(mod, setting) setattr(self, setting, setting_value) - course_settings = Settings() \ No newline at end of file diff --git a/djangoapps/courseware/grades.py b/djangoapps/courseware/grades.py index af33448b6c..072b3dd343 100644 --- a/djangoapps/courseware/grades.py +++ b/djangoapps/courseware/grades.py @@ -16,7 +16,6 @@ log = logging.getLogger("mitx.courseware") Score = namedtuple("Score", "earned possible graded section") SectionPercentage = namedtuple("SectionPercentage", "percentage label summary") - class CourseGrader(object): """ A course grader takes the totaled scores for each graded section (that a student has @@ -271,45 +270,6 @@ class AssignmentFormatGrader(CourseGrader): #No grade_breakdown here } -def get_score(user, problem, cache): - ## HACK: assumes max score is fixed per problem - id = problem.get('id') - correct = 0.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=float(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=float(courseware.modules.capa_module.Module(etree.tostring(problem), "id").max_score()) - response.max_grade = total - response.save() - - #Now we re-weight the problem, if specified - weight = problem.get("weight", None) - if weight: - weight = float(weight) - correct = correct * weight / total - total = weight - - return (correct, total) - def grade_sheet(student): """ This pulls a summary of all problems in the course. It returns a dictionary with two datastructures: @@ -405,3 +365,43 @@ def aggregate_scores(scores, section_name = "summary"): section_name) return all_total, graded_total + + +def get_score(user, problem, cache): + ## HACK: assumes max score is fixed per problem + id = problem.get('id') + correct = 0.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=float(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=float(courseware.modules.capa_module.Module(etree.tostring(problem), "id").max_score()) + response.max_grade = total + response.save() + + #Now we re-weight the problem, if specified + weight = problem.get("weight", None) + if weight: + weight = float(weight) + correct = correct * weight / total + total = weight + + return (correct, total) From e780e4d87e4dc60378a0d8d09c313544b63e28bb Mon Sep 17 00:00:00 2001 From: Bridger Maxwell Date: Tue, 17 Apr 2012 11:53:11 -0400 Subject: [PATCH 19/22] Put in precaution to be sure that any problems with a possible score of 0 are not graded. --- djangoapps/courseware/grades.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/djangoapps/courseware/grades.py b/djangoapps/courseware/grades.py index 072b3dd343..b50ef15669 100644 --- a/djangoapps/courseware/grades.py +++ b/djangoapps/courseware/grades.py @@ -313,6 +313,10 @@ def grade_sheet(student): correct = random.randrange( max(total-2, 1) , total + 1 ) else: correct = total + + if not total > 0: + #We simply cannot grade a problem that is 12/0, because we might need it as a percentage + graded = False scores.append( Score(correct,total, graded, p.get("name")) ) section_total, graded_total = aggregate_scores(scores, s.get("name")) From 9ee09ec93ad7151b51bccf9eabc48c0b15b282ce Mon Sep 17 00:00:00 2001 From: Bridger Maxwell Date: Tue, 17 Apr 2012 18:07:56 -0400 Subject: [PATCH 20/22] Put graders into their own file. Incorporated other feedback from pull request. --- djangoapps/courseware/__init__.py | 7 +- .../courseware/global_course_settings.py | 12 +- djangoapps/courseware/graders.py | 271 +++++++++++++++++ djangoapps/courseware/grades.py | 280 +----------------- djangoapps/courseware/tests.py | 56 ++-- 5 files changed, 320 insertions(+), 306 deletions(-) create mode 100644 djangoapps/courseware/graders.py diff --git a/djangoapps/courseware/__init__.py b/djangoapps/courseware/__init__.py index b8782ab30c..85e7242e3d 100644 --- a/djangoapps/courseware/__init__.py +++ b/djangoapps/courseware/__init__.py @@ -15,15 +15,15 @@ from courseware.course_settings import GRADER # This won't work. """ -import courseware import imp import logging import sys import types from django.conf import settings -from django.utils.functional import SimpleLazyObject + from courseware import global_course_settings +from courseware import graders _log = logging.getLogger("mitx.courseware") @@ -49,5 +49,8 @@ class Settings(object): if setting == setting.upper(): setting_value = getattr(mod, setting) setattr(self, setting, setting_value) + + # Here is where we should parse any configurations, so that we can fail early + self.GRADER = graders.grader_from_conf(self.GRADER) course_settings = Settings() \ No newline at end of file diff --git a/djangoapps/courseware/global_course_settings.py b/djangoapps/courseware/global_course_settings.py index b75ff6ee78..f4e9696d1d 100644 --- a/djangoapps/courseware/global_course_settings.py +++ b/djangoapps/courseware/global_course_settings.py @@ -1,27 +1,27 @@ GRADER = [ { - 'course_format' : "Homework", + 'type' : "Homework", 'min_count' : 12, 'drop_count' : 2, 'short_label' : "HW", 'weight' : 0.15, }, { - 'course_format' : "Lab", + 'type' : "Lab", 'min_count' : 12, 'drop_count' : 2, 'category' : "Labs", 'weight' : 0.15 }, { - 'section_format' : "Examination", - 'section_name' : "Midterm Exam", + 'type' : "Midterm", + 'name' : "Midterm Exam", 'short_label' : "Midterm", 'weight' : 0.3, }, { - 'section_format' : "Examination", - 'section_name' : "Final Exam", + 'type' : "Final", + 'name' : "Final Exam", 'short_label' : "Final", 'weight' : 0.4, } diff --git a/djangoapps/courseware/graders.py b/djangoapps/courseware/graders.py new file mode 100644 index 0000000000..e195dd3f5b --- /dev/null +++ b/djangoapps/courseware/graders.py @@ -0,0 +1,271 @@ +import logging + +from django.conf import settings + +from collections import namedtuple + +log = logging.getLogger("mitx.courseware") + +# This is a tuple for holding scores, either from problems or sections. +# Section either indicates the name of the problem or the name of the section +Score = namedtuple("Score", "earned possible graded section") + +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. + """ + if isinstance(conf, CourseGrader): + return conf + + subgraders = [] + for subgraderconf in conf: + subgraderconf = subgraderconf.copy() + weight = subgraderconf.pop("weight", 0) + try: + if 'min_count' in subgraderconf: + #This is an AssignmentFormatGrader + subgrader = AssignmentFormatGrader(**subgraderconf) + subgraders.append( (subgrader, subgrader.category, weight) ) + elif 'name' in subgraderconf: + #This is an SingleSectionGrader + subgrader = SingleSectionGrader(**subgraderconf) + subgraders.append( (subgrader, subgrader.category, weight) ) + else: + raise ValueError("Configuration has no appropriate grader class.") + + except (TypeError, ValueError) as error: + errorString = "Unable to parse grader configuration:\n " + str(subgraderconf) + "\n Error was:\n " + str(error) + log.critical(errorString) + raise ValueError(errorString) + + return WeightedSubsectionsGrader( subgraders ) + + +class CourseGrader(object): + """ + A course grader takes the totaled scores for each graded section (that a student has + started) in the course. From these scores, the grader calculates an overall percentage + grade. The grader should also generate information about how that score was calculated, + to be displayed in graphs or charts. + + A grader has one required method, grade(), which is passed a grade_sheet. The grade_sheet + contains scores for all graded section that the student has started. If a student has + a score of 0 for that section, it may be missing from the grade_sheet. The grade_sheet + is keyed by section format. Each value is a list of Score namedtuples for each section + that has the matching section format. + + The grader outputs a dictionary with the following keys: + - percent: Contaisn a float value, which is the final percentage score for the student. + - 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. + + A dictionary in the section_breakdown list has the following keys: + percent: A float percentage for the section. + label: A short string identifying the section. Preferably fixed-length. E.g. "HW 3". + detail: A string explanation of the score. E.g. "Homework 1 - Ohms Law - 83% (5/6)" + category: A string identifying the category. Items with the same category are grouped together + in the display (for example, by color). + 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: + 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 + in the display (for example, by color). + + + """ + 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 multiplying it by the + given weight. 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. + + Note that the sum of the weights is not take into consideration. If the weights add up to + 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 grade(self, grade_sheet): + total_percent = 0.0 + section_breakdown = [] + grade_breakdown = [] + + for subgrader, category, 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(category, weightedPercent, weight) + + total_percent += weightedPercent + section_breakdown += subgrade_result['section_breakdown'] + grade_breakdown.append( {'percent' : weightedPercent, '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): + self.type = type + self.name = name + self.short_label = short_label or name + self.category = category or name + + def grade(self, grade_sheet): + foundScore = None + if self.type in grade_sheet: + for score in grade_sheet[self.type]: + if score.section == self.name: + foundScore = score + break + + if foundScore: + percent = foundScore.earned / float(foundScore.possible) + detail = "{name} - {percent:.0%} ({earned:.3n}/{possible:.3n})".format( name = self.name, + percent = percent, + earned = float(foundScore.earned), + possible = float(foundScore.possible)) + + else: + percent = 0.0 + detail = "{name} - 0% (?/?)".format(name = self.name) + + if settings.GENERATE_PROFILE_SCORES: + points_possible = random.randrange(50, 100) + points_earned = random.randrange(40, points_possible) + percent = points_earned / float(points_possible) + detail = "{name} - {percent:.0%} ({earned:.3n}/{possible:.3n})".format( name = self.name, + percent = percent, + earned = float(points_earned), + possible = float(points_possible)) + + + + + 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 matching the format 'type' 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). + + min_count defines how many assignments are expected throughout the course. Placeholder + scores (of 0) will be inserted if the number of matching sections in the course is < min_count. + If there number of matching sections in the course is > min_count, min_count will be ignored. + + 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, type, min_count, drop_count, category = None, section_type = None, short_label = None): + self.type = type + self.min_count = min_count + self.drop_count = drop_count + self.category = category or self.type + self.section_type = section_type or self.type + self.short_label = short_label or self.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 = [] + if drop_count > 0: + 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'] + + if (len(breakdown) - drop_count > 0): + aggregate_score /= len(breakdown) - drop_count + + return aggregate_score, dropped_indices + + #Figure the homework scores + scores = grade_sheet.get(self.type, []) + breakdown = [] + for i in range( max(self.min_count, len(scores)) ): + if i < len(scores): + percentage = scores[i].earned / float(scores[i].possible) + summary = "{section_type} {index} - {name} - {percent:.0%} ({earned:.3n}/{possible:.3n})".format(index = i+1, + section_type = self.section_type, + name = scores[i].section, + percent = percentage, + earned = float(scores[i].earned), + possible = float(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:.3n}/{possible:.3n})".format(index = i+1, + section_type = self.section_type, + name = "Randomly Generated", + percent = percentage, + earned = float(points_earned), + possible = float(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 + } diff --git a/djangoapps/courseware/grades.py b/djangoapps/courseware/grades.py index b50ef15669..8b4cb22c2f 100644 --- a/djangoapps/courseware/grades.py +++ b/djangoapps/courseware/grades.py @@ -1,274 +1,13 @@ -import courseware.content_parser as content_parser -import courseware.modules -import logging -import random -import urllib - -from collections import namedtuple -from courseware import course_settings -from django.conf import settings from lxml import etree +import random + +from django.conf import settings + +from courseware import course_settings +import courseware.content_parser as content_parser +from courseware.graders import Score +import courseware.modules from models import StudentModule -from student.models import UserProfile - -log = logging.getLogger("mitx.courseware") - -Score = namedtuple("Score", "earned possible graded section") -SectionPercentage = namedtuple("SectionPercentage", "percentage label summary") - -class CourseGrader(object): - """ - A course grader takes the totaled scores for each graded section (that a student has - started) in the course. From these scores, the grader calculates an overall percentage - grade. The grader should also generate information about how that score was calculated, - to be displayed in graphs or charts. - - A grader has one required method, grade(), which is passed a grade_sheet. The grade_sheet - contains scores for all graded section that the student has started. If a student has - a score of 0 for that section, it may be missing from the grade_sheet. The grade_sheet - is keyed by section format. Each value is a list of Score namedtuples for each section - that has the matching section format. - - The grader outputs a dictionary with the following keys: - - percent: Contaisn a float value, which is the final percentage score for the student. - - 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. - - A dictionary in the section_breakdown list has the following keys: - percent: A float percentage for the section. - label: A short string identifying the section. Preferably fixed-length. E.g. "HW 3". - detail: A string explanation of the score. E.g. "Homework 1 - Ohms Law - 83% (5/6)" - category: A string identifying the category. Items with the same category are grouped together - in the display (for example, by color). - 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: - 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 - in the display (for example, by color). - - - """ - def grade(self, grade_sheet): - raise NotImplementedError - - - @classmethod - def graderFromConf(cls, conf): - if isinstance(conf, CourseGrader): - return conf - - subgraders = [] - for subgraderconf in conf: - subgraderconf = subgraderconf.copy() - weight = subgraderconf.pop("weight", 0) - try: - if 'min_count' in subgraderconf: - #This is an AssignmentFormatGrader - subgrader = AssignmentFormatGrader(**subgraderconf) - subgraders.append( (subgrader, subgrader.category, weight) ) - elif 'section_name' in subgraderconf: - #This is an SingleSectionGrader - subgrader = SingleSectionGrader(**subgraderconf) - subgraders.append( (subgrader, subgrader.category, weight) ) - else: - raise ValueError("Configuration has no appropriate grader class.") - - except TypeError as error: - log.error("Unable to parse grader configuration:\n" + subgraderconf + "\nError was:\n" + error) - except ValueError as error: - log.error("Unable to parse grader configuration:\n" + subgraderconf + "\nError was:\n" + error) - - return WeightedSubsectionsGrader( subgraders ) - - -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 multiplying it by the - given weight. 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. - - Note that the sum of the weights is not take into consideration. If the weights add up to - 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 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:.3n}/{possible:.3n})".format( name = self.section_name, - percent = percent, - earned = float(foundScore.earned), - possible = float(foundScore.possible)) - - else: - percent = 0.0 - detail = "{name} - 0% (?/?)".format(name = self.section_name) - - if settings.GENERATE_PROFILE_SCORES: - points_possible = random.randrange(50, 100) - points_earned = random.randrange(40, points_possible) - percent = points_earned / float(points_possible) - detail = "{name} - {percent:.0%} ({earned:.3n}/{possible:.3n})".format( name = self.section_name, - percent = percent, - earned = float(points_earned), - possible = float(points_possible)) - - - - - 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). - - min_count defines how many assignments are expected throughout the course. Placeholder - scores (of 0) will be inserted if the number of matching sections in the course is < min_count. - If there number of matching sections in the course is > min_count, min_count will be ignored. - - 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_count, drop_count, category = None, section_type = None, short_label = None): - self.course_format = course_format - self.min_count = min_count - self.drop_count = drop_count - self.category = category or self.course_format - self.section_type = section_type or self.course_format - self.short_label = short_label or self.course_format - - 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 = [] - if drop_count > 0: - 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'] - - if (len(breakdown) - drop_count > 0): - aggregate_score /= len(breakdown) - drop_count - - return aggregate_score, dropped_indices - - #Figure the homework scores - scores = grade_sheet.get(self.course_format, []) - breakdown = [] - for i in range( max(self.min_count, len(scores)) ): - if i < len(scores): - percentage = scores[i].earned / float(scores[i].possible) - summary = "{section_type} {index} - {name} - {percent:.0%} ({earned:.3n}/{possible:.3n})".format(index = i+1, - section_type = self.section_type, - name = scores[i].section, - percent = percentage, - earned = float(scores[i].earned), - possible = float(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:.3n}/{possible:.3n})".format(index = i+1, - section_type = self.section_type, - name = "Randomly Generated", - percent = percentage, - earned = float(points_earned), - possible = float(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 grade_sheet(student): """ @@ -343,8 +82,7 @@ def grade_sheet(student): 'sections' : sections,}) - grader = CourseGrader.graderFromConf(course_settings.GRADER) - #TODO: We should cache this grader object + grader = course_settings.GRADER grade_summary = grader.grade(totaled_scores) return {'courseware_summary' : chapters, diff --git a/djangoapps/courseware/tests.py b/djangoapps/courseware/tests.py index 529d7bb473..db07e36e04 100644 --- a/djangoapps/courseware/tests.py +++ b/djangoapps/courseware/tests.py @@ -4,7 +4,9 @@ import numpy import courseware.modules import courseware.capa.calc as calc -from grades import Score, aggregate_scores, CourseGrader, WeightedSubsectionsGrader, SingleSectionGrader, AssignmentFormatGrader +import courseware.graders as graders +from courseware.graders import Score, CourseGrader, WeightedSubsectionsGrader, SingleSectionGrader, AssignmentFormatGrader +from courseware.grades import aggregate_scores class ModelsTest(unittest.TestCase): def setUp(self): @@ -107,9 +109,9 @@ class GraderTest(unittest.TestCase): } def test_SingleSectionGrader(self): - midtermGrader = SingleSectionGrader("Midterm", "Midterm Exam") - lab4Grader = SingleSectionGrader("Lab", "lab4") - badLabGrader = SingleSectionGrader("Lab", "lab42") + midtermGrader = graders.SingleSectionGrader("Midterm", "Midterm Exam") + lab4Grader = graders.SingleSectionGrader("Lab", "lab4") + badLabGrader = graders.SingleSectionGrader("Lab", "lab42") for graded in [midtermGrader.grade(self.empty_gradesheet), midtermGrader.grade(self.incomplete_gradesheet), @@ -125,12 +127,12 @@ class GraderTest(unittest.TestCase): self.assertAlmostEqual( graded['percent'], 0.2 ) self.assertEqual( len(graded['section_breakdown']), 1 ) - def test_assignmentFormatGrader(self): - homeworkGrader = AssignmentFormatGrader("Homework", 12, 2) - noDropGrader = AssignmentFormatGrader("Homework", 12, 0) + def test_AssignmentFormatGrader(self): + homeworkGrader = graders.AssignmentFormatGrader("Homework", 12, 2) + noDropGrader = graders.AssignmentFormatGrader("Homework", 12, 0) #Even though the minimum number is 3, this should grade correctly when 7 assignments are found - overflowGrader = AssignmentFormatGrader("Lab", 3, 2) - labGrader = AssignmentFormatGrader("Lab", 7, 3) + overflowGrader = graders.AssignmentFormatGrader("Lab", 3, 2) + labGrader = graders.AssignmentFormatGrader("Lab", 7, 3) #Test the grading of an empty gradesheet @@ -162,25 +164,25 @@ class GraderTest(unittest.TestCase): def test_WeightedSubsectionsGrader(self): #First, a few sub graders - homeworkGrader = AssignmentFormatGrader("Homework", 12, 2) - labGrader = AssignmentFormatGrader("Lab", 7, 3) - midtermGrader = SingleSectionGrader("Midterm", "Midterm Exam") + homeworkGrader = graders.AssignmentFormatGrader("Homework", 12, 2) + labGrader = graders.AssignmentFormatGrader("Lab", 7, 3) + midtermGrader = graders.SingleSectionGrader("Midterm", "Midterm Exam") - weightedGrader = WeightedSubsectionsGrader( [(homeworkGrader, homeworkGrader.category, 0.25), (labGrader, labGrader.category, 0.25), + weightedGrader = graders.WeightedSubsectionsGrader( [(homeworkGrader, homeworkGrader.category, 0.25), (labGrader, labGrader.category, 0.25), (midtermGrader, midtermGrader.category, 0.5)] ) - overOneWeightsGrader = WeightedSubsectionsGrader( [(homeworkGrader, homeworkGrader.category, 0.5), (labGrader, labGrader.category, 0.5), + overOneWeightsGrader = graders.WeightedSubsectionsGrader( [(homeworkGrader, homeworkGrader.category, 0.5), (labGrader, labGrader.category, 0.5), (midtermGrader, midtermGrader.category, 0.5)] ) #The midterm should have all weight on this one - zeroWeightsGrader = WeightedSubsectionsGrader( [(homeworkGrader, homeworkGrader.category, 0.0), (labGrader, labGrader.category, 0.0), + zeroWeightsGrader = graders.WeightedSubsectionsGrader( [(homeworkGrader, homeworkGrader.category, 0.0), (labGrader, labGrader.category, 0.0), (midtermGrader, midtermGrader.category, 0.5)] ) #This should always have a final percent of zero - allZeroWeightsGrader = WeightedSubsectionsGrader( [(homeworkGrader, homeworkGrader.category, 0.0), (labGrader, labGrader.category, 0.0), + allZeroWeightsGrader = graders.WeightedSubsectionsGrader( [(homeworkGrader, homeworkGrader.category, 0.0), (labGrader, labGrader.category, 0.0), (midtermGrader, midtermGrader.category, 0.0)] ) - emptyGrader = WeightedSubsectionsGrader( [] ) + emptyGrader = graders.WeightedSubsectionsGrader( [] ) graded = weightedGrader.grade(self.test_gradesheet) self.assertAlmostEqual( graded['percent'], 0.5106547619047619 ) @@ -221,33 +223,33 @@ class GraderTest(unittest.TestCase): def test_graderFromConf(self): - #Confs always produce a WeightedSubsectionsGrader, so we test this by repeating the test - #in test_WeightedSubsectionsGrader, but generate the graders with confs. + #Confs always produce a graders.WeightedSubsectionsGrader, so we test this by repeating the test + #in test_graders.WeightedSubsectionsGrader, but generate the graders with confs. - weightedGrader = CourseGrader.graderFromConf([ + weightedGrader = graders.grader_from_conf([ { - 'course_format' : "Homework", + 'type' : "Homework", 'min_count' : 12, 'drop_count' : 2, 'short_label' : "HW", 'weight' : 0.25, }, { - 'course_format' : "Lab", + 'type' : "Lab", 'min_count' : 7, 'drop_count' : 3, 'category' : "Labs", 'weight' : 0.25 }, { - 'section_format' : "Midterm", - 'section_name' : "Midterm Exam", + 'type' : "Midterm", + 'name' : "Midterm Exam", 'short_label' : "Midterm", 'weight' : 0.5, }, ]) - emptyGrader = CourseGrader.graderFromConf([]) + emptyGrader = graders.grader_from_conf([]) graded = weightedGrader.grade(self.test_gradesheet) self.assertAlmostEqual( graded['percent'], 0.5106547619047619 ) @@ -260,8 +262,8 @@ class GraderTest(unittest.TestCase): self.assertEqual( len(graded['grade_breakdown']), 0 ) #Test that graders can also be used instead of lists of dictionaries - homeworkGrader = AssignmentFormatGrader("Homework", 12, 2) - homeworkGrader2 = CourseGrader.graderFromConf(homeworkGrader) + homeworkGrader = graders.AssignmentFormatGrader("Homework", 12, 2) + homeworkGrader2 = graders.grader_from_conf(homeworkGrader) graded = homeworkGrader2.grade(self.test_gradesheet) self.assertAlmostEqual( graded['percent'], 0.11 ) From f987f99a06fab1b5b48509561100365871e8606f Mon Sep 17 00:00:00 2001 From: Bridger Maxwell Date: Tue, 17 Apr 2012 18:17:43 -0400 Subject: [PATCH 21/22] Changed CourseGrader to be an abstract base class. --- djangoapps/courseware/graders.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/djangoapps/courseware/graders.py b/djangoapps/courseware/graders.py index e195dd3f5b..94b2ca78cd 100644 --- a/djangoapps/courseware/graders.py +++ b/djangoapps/courseware/graders.py @@ -1,3 +1,4 @@ +import abc import logging from django.conf import settings @@ -86,6 +87,10 @@ class CourseGrader(object): """ + + __metaclass__ = abc.ABCMeta + + @abc.abstractmethod def grade(self, grade_sheet): raise NotImplementedError From e9247920afba4b8fce0954c01abb533360fec4ba Mon Sep 17 00:00:00 2001 From: Bridger Maxwell Date: Tue, 17 Apr 2012 18:28:59 -0400 Subject: [PATCH 22/22] Moved potential failure outside of unrelated try/catch block that would have masked the missing settings.DATA_DIR --- djangoapps/courseware/__init__.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/djangoapps/courseware/__init__.py b/djangoapps/courseware/__init__.py index 85e7242e3d..be88d22640 100644 --- a/djangoapps/courseware/__init__.py +++ b/djangoapps/courseware/__init__.py @@ -33,13 +33,16 @@ class Settings(object): for setting in dir(global_course_settings): if setting == setting.upper(): setattr(self, setting, getattr(global_course_settings, setting)) - + + + data_dir = settings.DATA_DIR + fp = None try: - fp, pathname, description = imp.find_module("course_settings", [settings.DATA_DIR]) + fp, pathname, description = imp.find_module("course_settings", [data_dir]) mod = imp.load_module("course_settings", fp, pathname, description) except Exception as e: - _log.error("Unable to import course settings file from " + settings.DATA_DIR + ". Error: " + str(e)) + _log.exception("Unable to import course settings file from " + data_dir + ". Error: " + str(e)) mod = types.ModuleType('course_settings') finally: if fp: