fix ruby calculation of grades (grade-dropping)

fixes #9577

Test plan:
  * set up some assignments in an assignment group with grade-dropping
    rules configured
  * use the courses api to check the current and final scores in the
    course, and compare those to the values in the gradebook
    - note that computed_final_score is equivalent to the score in
      gradebook when 'Treat ungraded as 0s' is enabled.

Change-Id: I2f60f26ae849caf5325466171639274148d6bc56
Reviewed-on: https://gerrit.instructure.com/12416
Tested-by: Jenkins <jenkins@instructure.com>
Reviewed-by: Zach Pendleton <zachp@instructure.com>
This commit is contained in:
Cameron Matheson 2012-07-23 10:47:44 -06:00
parent de1ae8ce93
commit 63f75597a2
2 changed files with 179 additions and 80 deletions

View File

@ -51,7 +51,7 @@ class GradeCalculator
end
end
:private
private
# The score ignoring unsubmitted assignments
def calculate_current_score(user_id, submissions)
@ -67,92 +67,126 @@ class GradeCalculator
@final_updates << "WHEN user_id=#{user_id} THEN #{score || "NULL"}"
end
# Creates a hash for each assignment group with stats and the end score for each group
# returns information about assignments groups in the form:
# {1=>
# {:tally=>0.818181818181818,
# :submission_count=>2,
# :total_points=>110,
# :user_points=>90,
# :name=>"Assignments",
# :weighted_tally=>0,
# :group_weight=>0},
# 5=> {...}}
# each group
def create_group_sums(submissions, ignore_ungraded=true)
assignments_by_group_id = @assignments.group_by(&:assignment_group_id)
submissions_by_assignment_id = Hash[
submissions.map { |s| [s.assignment_id, s] }
]
group_sums = {}
@groups.each do |group|
group_assignments = @assignments.select { |a| a.assignment_group_id == group.id }
assignment_submissions = []
sums = {:name => group.name,
:total_points => 0,
:user_points => 0,
:group_weight => group.group_weight || 0,
:submission_count => 0}
assignments = assignments_by_group_id[group.id] || []
# collect submissions for this user for all the assignments
# if an assignment is muted it will be treated as if there is no submission
group_assignments.each do |assignment|
submission = submissions.detect { |s| s.assignment_id == assignment.id }
submission = nil if assignment.muted
submission ||= OpenStruct.new(:assignment_id=>assignment.id, :score=>0) unless ignore_ungraded
assignment_submissions << {:assignment => assignment, :submission => submission}
group_submissions = assignments.map do |a|
s = submissions_by_assignment_id[a.id]
# ignore submissions for muted assignments
s = nil if a.muted?
{
:assignment => a,
:submission => s,
:score => s && s.score,
:total => a.points_possible
}
end
# Sort the submissions that have a grade by score (to easily drop lowest/highest grades)
if ignore_ungraded
sorted_assignment_submissions = assignment_submissions.select { |hash| hash[:submission] && hash[:submission].score }
else
sorted_assignment_submissions = assignment_submissions.select { |hash| hash[:submission] }
end
sorted_assignment_submissions = sorted_assignment_submissions.sort_by do |hash|
val = (((hash[:submission].score || 0)/ hash[:assignment].points_possible) rescue 999999)
val.to_f.finite? ? val : 999999
end
# Mark the assignments that aren't allowed to be dropped
if group.rules_hash[:never_drop]
sorted_assignment_submissions.each do |hash|
never_drop = group.rules_hash[:never_drop].include?(hash[:assignment].id)
hash[:never_drop] = true if never_drop
end
end
# Flag the the lowest assignments to be dropped
low_drop_count = 0
high_drop_count = 0
total_scored = sorted_assignment_submissions.length
if group.rules_hash[:drop_lowest]
drop_total = group.rules_hash[:drop_lowest] || 0
sorted_assignment_submissions.each do |hash|
if !hash[:drop] && !hash[:never_drop] && low_drop_count < drop_total && (low_drop_count + high_drop_count + 1) < total_scored
low_drop_count += 1
hash[:drop] = true
end
end
end
# Flag the highest assignments to be dropped
if group.rules_hash[:drop_highest]
drop_total = group.rules_hash[:drop_highest] || 0
sorted_assignment_submissions.reverse.each do |hash|
if !hash[:drop] && !hash[:never_drop] && high_drop_count < drop_total && (low_drop_count + high_drop_count + 1) < total_scored
high_drop_count += 1
hash[:drop] = true
end
end
end
# If all submissions are marked to be dropped: don't drop the highest because we need at least one
if !sorted_assignment_submissions.empty? && sorted_assignment_submissions.all? { |hash| hash[:drop] }
sorted_assignment_submissions[-1][:drop] = false
end
# Count the points from all the non-dropped submissions
sorted_assignment_submissions.select { |hash| !hash[:drop] }.each do |hash|
sums[:submission_count] += 1
sums[:total_points] += hash[:assignment].points_possible || 0
sums[:user_points] += (hash[:submission] && hash[:submission].score) || 0
end
# Calculate the tally for this group
sums[:group_weight] = group.group_weight || 0
sums[:tally] = sums[:user_points].to_f / sums[:total_points].to_f
sums[:tally] = 0.0 unless sums[:tally].finite?
sums[:weighted_tally] = sums[:tally] * sums[:group_weight].to_f
group_sums[group.id] = sums
group_submissions.reject! { |s| s[:score].nil? } if ignore_ungraded
group_submissions.reject! { |s| s[:total].to_i.zero? }
group_submissions.each { |s| s[:score] ||= 0 }
kept = drop_assignments(group_submissions, group.rules_hash)
score, possible = kept.inject([0, 0]) { |(s_sum,p_sum),s|
[s_sum + s[:score], p_sum + s[:total]]
}
grade = score.to_f / possible
weight = group.group_weight.to_f
group_sums[group.id] = {
:name => group.name,
:tally => grade,
:submission_count => kept.size,
:total_points => possible,
:user_points => score,
:group_weight => weight,
:weighted_tally => grade * weight,
}
end
group_sums
end
# see comments for dropAssignments in grade_calculator.coffee
def drop_assignments(submissions, rules)
drop_lowest = rules[:drop_lowest] || 0
drop_highest = rules[:drop_highest] || 0
never_drop_ids = rules[:never_drop] || []
return submissions if drop_lowest.zero? && drop_highest.zero?
if never_drop_ids.empty?
cant_drop = []
else
cant_drop, submissions = submissions.partition { |s|
never_drop_ids.include? s[:assignment].id
}
end
# fudge the drop rules if there aren't enough submissions
return cant_drop if submissions.empty?
drop_lowest = submissions.size - 1 if drop_lowest >= submissions.size
drop_highest = 0 if drop_lowest + drop_highest >= submissions.size
totals = submissions.map { |s| s[:total] }
max_total = totals.max
grades = submissions.map { |s| s[:score].to_f / s[:total] }.sort
q_low = grades.first
q_high = grades.last
q_mid = (q_low + q_high) / 2
keep_highest = submissions.size - drop_lowest
x, kept = big_f(q_mid, submissions, keep_highest)
threshold = 1 / (2 * keep_highest * max_total**2)
until q_high - q_low < threshold
x < 0 ?
q_high = q_mid :
q_low = q_mid
q_mid = (q_low + q_high) / 2
x, kept = big_f(q_mid, submissions, keep_highest)
end
kept.shift(drop_highest)
kept += cant_drop
kept
end
# helper function for drop_assignments
#
# returns a new q value (which should eventually approach 0) and any
# submissions kept according to the given value of q
def big_f(q, submissions, keep)
kept = submissions.map { |s|
rated_score = s[:score] - q * s[:total]
[rated_score, s]
}.sort { |(a_rated_score,az), (b_rated_score,bz)|
b_rated_score <=> a_rated_score
}.first(keep)
q_kept = kept.reduce(0) { |sum,(rated_score,z)| sum + rated_score }
[q_kept, kept.map(&:last)]
end
# Calculates the final score from the sums of all the assignment groups
def calculate_total_from_group_scores(group_sums, ignore_ungraded=true)

View File

@ -364,7 +364,72 @@ describe GradeCalculator do
@user.enrollments.first.computed_current_score.should eql(52.5)
@user.enrollments.first.computed_final_score.should eql(43.6)
end
end
# We should keep this in sync with GradeCalculatorSpec.coffee
context "GradeCalculatorSpec.coffee examples" do
before do
course_with_student
@grades = [[100,100], [42,91], [14,55], [3,38], [nil,1000]]
@group = @course.assignment_groups.create! :name => 'group 1'
@assignments = @grades.map do |score,possible|
@course.assignments.create! :title => 'homework',
:points_possible => possible,
:assignment_group => @group
end
end
def set_default_grades
@assignments.each_with_index do |a,i|
score = @grades[i].first
next unless score # don't grade nil submissions
a.grade_student @student, :grade => score
end
end
def check_grades(current, final)
GradeCalculator.recompute_final_score(@student.id, @course.id)
@enrollment.reload
@enrollment.computed_current_score.should == current
@enrollment.computed_final_score.should == final
end
it "should work without assignments or submissions" do
@group.assignments.clear
check_grades(nil, nil)
end
it "should work without submissions" do
check_grades(nil, 0)
end
it "should work with no drop rules" do
set_default_grades
check_grades(56.0, 12.4)
end
it "should support drop_lowest" do
set_default_grades
@group.update_attribute(:rules, 'drop_lowest:1')
check_grades(63.4, 56.0)
@group.update_attribute(:rules, 'drop_lowest:2')
check_grades(74.6, 63.4)
end
it "should support drop_highest" do
set_default_grades
@group.update_attribute(:rules, 'drop_highest:1')
check_grades(32.1, 5.0)
end
it "should support never_drop" do
set_default_grades
rules = "drop_lowest:1\nnever_drop:#{@assignments[3].id}" # 3/38
@group.update_attribute(:rules, rules)
check_grades(63.3, 56.0)
end
end
end