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:
parent
de1ae8ce93
commit
63f75597a2
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue