canvas-lms/lib/grade_calculator.rb

389 lines
14 KiB
Ruby
Raw Normal View History

2011-02-01 09:57:29 +08:00
#
# Copyright (C) 2011 - 2014 Instructure, Inc.
2011-02-01 09:57:29 +08:00
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
class GradeCalculator
attr_accessor :submissions, :assignments, :groups
def initialize(user_ids, course, opts = {})
opts = opts.reverse_merge(:ignore_muted => true)
Rails.logger.info("GRADES: calc args: user_ids=#{user_ids.inspect}")
Rails.logger.info("GRADES: calc args: course=#{course.inspect}")
Rails.logger.info("GRADES: calc args: opts=#{opts.inspect}")
@course = course.is_a?(Course) ? course : Course.find(course)
@groups = @course.assignment_groups.active
@grading_period = opts[:grading_period]
assignment_scope = @course.assignments.published.gradeable
feature flag for 'All Grading Periods' totals Add grading period dropdowns on the 'grades' page, and add a "Display Totals for 'All Grading Periods'" feature flag. By default, the feature will be turned 'off'. When the feature is 'off': - Totals will not display in the gradebook or the 'student grades' page when the 'All Grading Periods' option is selected. - The grading period dropdowns on the 'grades' page will not have an 'All Grading Periods' option. When the feature is 'on': - Totals will display in the gradebook and the 'student grades' page when the 'All Grading Periods' option is selected. - The grading period dropdowns on the 'grades' page will have an 'All Grading Periods' option. closes CNVS-23995 test plan: 1) as a teacher, enable the 'multiple grading periods' feature (do not enable the 'display totals for all grading periods' feature yet). a) verify the gradebook does not show totals when the 'All Grading Periods' option is selected. b) verify the 'student grades page' (courses/4/grades/9#tab-assignments) does not show totals, and the calculation of 'what-if' grades is disabled when the 'All Grading Periods' option is selected. c) turn on the 'display totals for all grading periods' feature. repeat steps a & b and verify that the totals now show up (and you can calculate what-if grades on the student grades page when 'All Grading Periods is selected') 2) sign in as a student that is enrolled in 3 courses: 1 course with MGP disabled, 1 course with MGP enabled and 'display all grading periods totals' (DAGPT) disabled, and 1 course with MGP enabled and DAGPT enabled. go the the 'grades' page (/grades). a) verify there is a grading period dropdown next to the totals for courses that have MGP enabled. verify there is not a grading period dropdown next to the total for the course with MGP disabled. b) verify that the current grading period is selected by default, if one exists. if a current grading period does not exist, then: - the dropdown next to the total for the course with DAGPT disabled should show 'Select a grading period' and the total grade should show as '--'. - the dropdown next to the total for the course with DAGPT enabled should show 'All Grading Periods' and the total grade should be displayed. c) verify clicking a grading period in the dropdown changes the total, and shows the correct total for that grading period. 3) repeat steps 2a-c, but sign in as an observer that is observing at least 3 students in 3 different courses(1 course with MGP disabled, 1 with MGP enabled and DAGPT disabled, and 1 course with MGP enabled + DAGPT enabled). 4) verify that the grading period dropdowns that were added are accessible. Note: The 'grades' page (/grades) will _always_ display the total for 'All Grading Periods' when signed in as a teacher. We are aware of this existing bug and we're working on a solution. Change-Id: If501b47aa57121d17d4e6629d1dcdbc8676971a2 Reviewed-on: https://gerrit.instructure.com/65847 Tested-by: Jenkins Reviewed-by: Strand McCutchen <smccutchen@instructure.com> Reviewed-by: Dylan Ross <dross@instructure.com> Reviewed-by: Derek Bender <djbender@instructure.com> QA-Review: Jason Carter <jcarter@instructure.com> Product-Review: Spencer Olson <solson@instructure.com>
2015-10-14 03:20:03 +08:00
@assignments = assignment_scope.to_a
2011-02-01 09:57:29 +08:00
@user_ids = Array(user_ids).map(&:to_i)
@current_updates = {}
@final_updates = {}
@ignore_muted = opts[:ignore_muted]
2011-02-01 09:57:29 +08:00
end
# recomputes the scores and saves them to each user's Enrollment
def self.recompute_final_score(user_ids, course_id)
user_ids = Array(user_ids).uniq.map(&:to_i)
return if user_ids.empty?
user_ids.in_groups_of(1000, false) do |user_ids_group|
calc = GradeCalculator.new user_ids_group, course_id
calc.compute_and_save_scores
end
2011-02-01 09:57:29 +08:00
end
def compute_scores
@submissions = @course.submissions.
except(:order, :select).
for_user(@user_ids).
where(assignment_id: @assignments.map(&:id)).
select("submissions.id, user_id, assignment_id, score, excused")
submissions_by_user = @submissions.group_by(&:user_id)
scores = []
@user_ids.each_slice(100) do |batched_ids|
load_assignment_visibilities_for_users(batched_ids)
batched_ids.each do |user_id|
user_submissions = submissions_by_user[user_id] || []
if differentiated_assignments_on?
user_submissions.select!{|s| assignment_ids_visible_to_user(user_id).include?(s.assignment_id)}
end
current, current_groups = calculate_current_score(user_id, user_submissions)
final, final_groups = calculate_final_score(user_id, user_submissions)
scores << [[current, current_groups], [final, final_groups]]
end
clear_assignment_visibilities_cache
2011-02-01 09:57:29 +08:00
end
scores
end
def compute_and_save_scores
compute_scores
save_scores
end
private
def differentiated_assignments_on?
@differentiated_assignments_enabled ||= @course.feature_enabled?(:differentiated_assignments)
end
def save_scores
raise "Can't save scores when ignore_muted is false" unless @ignore_muted
Course.where(:id => @course).update_all(:updated_at => Time.now.utc)
time = Enrollment.sanitize(Time.now.utc)
query = "updated_at=#{time}, graded_at=#{time}"
query += ", computed_current_score=CASE user_id #{@current_updates.map { |user_id, grade| "WHEN #{user_id} THEN #{grade || "NULL"}"}.
join(" ")} ELSE computed_current_score END"
query += ", computed_final_score=CASE user_id #{@final_updates.map { |user_id, grade| "WHEN #{user_id} THEN #{grade || "NULL"}"}.
join(" ")} ELSE computed_final_score END"
Enrollment.where(:user_id => @user_ids, :course_id => @course).update_all(query)
2011-02-01 09:57:29 +08:00
end
# The score ignoring unsubmitted assignments
def calculate_current_score(user_id, submissions)
calculate_score(submissions, user_id, @current_updates, true)
2011-02-01 09:57:29 +08:00
end
# The final score for the class, so unsubmitted assignments count as zeros
def calculate_final_score(user_id, submissions)
calculate_score(submissions, user_id, @final_updates, false)
end
def calculate_score(submissions, user_id, grade_updates, ignore_ungraded)
group_sums = create_group_sums(submissions, user_id, ignore_ungraded)
info = calculate_total_from_group_scores(group_sums)
Rails.logger.info "GRADES: calculated: #{info.inspect}"
grade_updates[user_id] = info[:grade]
[info, group_sums.index_by { |s| s[:id] }]
2011-02-01 09:57:29 +08:00
end
# returns information about assignments groups in the form:
# [
# {
# :id => 1
# :score => 5,
# :possible => 7,
# :grade => 71.42,
# :weight => 50},
# ...]
# each group
def create_group_sums(submissions, user_id, ignore_ungraded=true)
visible_assignments = @assignments
if differentiated_assignments_on?
visible_assignments = visible_assignments.select{|a| assignment_ids_visible_to_user(user_id).include?(a.id)}
end
feature flag for 'All Grading Periods' totals Add grading period dropdowns on the 'grades' page, and add a "Display Totals for 'All Grading Periods'" feature flag. By default, the feature will be turned 'off'. When the feature is 'off': - Totals will not display in the gradebook or the 'student grades' page when the 'All Grading Periods' option is selected. - The grading period dropdowns on the 'grades' page will not have an 'All Grading Periods' option. When the feature is 'on': - Totals will display in the gradebook and the 'student grades' page when the 'All Grading Periods' option is selected. - The grading period dropdowns on the 'grades' page will have an 'All Grading Periods' option. closes CNVS-23995 test plan: 1) as a teacher, enable the 'multiple grading periods' feature (do not enable the 'display totals for all grading periods' feature yet). a) verify the gradebook does not show totals when the 'All Grading Periods' option is selected. b) verify the 'student grades page' (courses/4/grades/9#tab-assignments) does not show totals, and the calculation of 'what-if' grades is disabled when the 'All Grading Periods' option is selected. c) turn on the 'display totals for all grading periods' feature. repeat steps a & b and verify that the totals now show up (and you can calculate what-if grades on the student grades page when 'All Grading Periods is selected') 2) sign in as a student that is enrolled in 3 courses: 1 course with MGP disabled, 1 course with MGP enabled and 'display all grading periods totals' (DAGPT) disabled, and 1 course with MGP enabled and DAGPT enabled. go the the 'grades' page (/grades). a) verify there is a grading period dropdown next to the totals for courses that have MGP enabled. verify there is not a grading period dropdown next to the total for the course with MGP disabled. b) verify that the current grading period is selected by default, if one exists. if a current grading period does not exist, then: - the dropdown next to the total for the course with DAGPT disabled should show 'Select a grading period' and the total grade should show as '--'. - the dropdown next to the total for the course with DAGPT enabled should show 'All Grading Periods' and the total grade should be displayed. c) verify clicking a grading period in the dropdown changes the total, and shows the correct total for that grading period. 3) repeat steps 2a-c, but sign in as an observer that is observing at least 3 students in 3 different courses(1 course with MGP disabled, 1 with MGP enabled and DAGPT disabled, and 1 course with MGP enabled + DAGPT enabled). 4) verify that the grading period dropdowns that were added are accessible. Note: The 'grades' page (/grades) will _always_ display the total for 'All Grading Periods' when signed in as a teacher. We are aware of this existing bug and we're working on a solution. Change-Id: If501b47aa57121d17d4e6629d1dcdbc8676971a2 Reviewed-on: https://gerrit.instructure.com/65847 Tested-by: Jenkins Reviewed-by: Strand McCutchen <smccutchen@instructure.com> Reviewed-by: Dylan Ross <dross@instructure.com> Reviewed-by: Derek Bender <djbender@instructure.com> QA-Review: Jason Carter <jcarter@instructure.com> Product-Review: Spencer Olson <solson@instructure.com>
2015-10-14 03:20:03 +08:00
if @grading_period
user = @course.users.find(user_id)
visible_assignments = @grading_period.assignments_for_student(visible_assignments, user)
end
assignments_by_group_id = visible_assignments.group_by(&:assignment_group_id)
submissions_by_assignment_id = Hash[
submissions.map { |s| [s.assignment_id, s] }
]
@groups.map do |group|
assignments = assignments_by_group_id[group.id] || []
group_submissions = assignments.map do |a|
s = submissions_by_assignment_id[a.id]
# ignore submissions for muted assignments
s = nil if @ignore_muted && a.muted?
{
assignment: a,
submission: s,
score: s && s.score,
total: a.points_possible || 0,
excused: s && s.excused?,
}
end
group_submissions.reject! { |s| s[:score].nil? } if ignore_ungraded
group_submissions.reject! { |s| s[:excused] }
group_submissions.each { |s| s[:score] ||= 0 }
logged_submissions = group_submissions.map { |s| loggable_submission(s) }
Rails.logger.info "GRADES: calculating for assignment_group=#{group.global_id} user=#{user_id}"
Rails.logger.info "GRADES: calculating... ignore_ungraded=#{ignore_ungraded}"
Rails.logger.info "GRADES: calculating... submissions=#{logged_submissions.inspect}"
kept = drop_assignments(group_submissions, group.rules_hash)
score, possible = kept.reduce([0, 0]) { |(s_sum,p_sum),s|
[s_sum + s[:score], p_sum + s[:total]]
}
{
:id => group.id,
:score => score,
:possible => possible,
:weight => group.group_weight,
:grade => ((score.to_f / possible * 100).round(2) if possible > 0),
}.tap { |group_grade_info|
Rails.logger.info "GRADES: calculated #{group_grade_info.inspect}"
}
2011-02-01 09:57:29 +08:00
end
end
def load_assignment_visibilities_for_users(user_ids)
@assignment_ids_visible_to_user ||= begin
if differentiated_assignments_on?
AssignmentStudentVisibility.visible_assignment_ids_in_course_by_user(course_id: @course.id, user_id: user_ids)
else
assignment_ids = @assignments.map(&:id)
user_ids.reduce({}){|hash, id| hash[id] = assignment_ids; hash}
end
end
end
def clear_assignment_visibilities_cache
@assignment_ids_visible_to_user = nil
end
def assignment_ids_visible_to_user(user_id)
@assignment_ids_visible_to_user[user_id]
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?
Rails.logger.info "GRADES: dropping assignments! #{rules.inspect}"
cant_drop = []
if never_drop_ids.present?
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
keep_highest = submissions.size - drop_lowest
keep_lowest = keep_highest - drop_highest
submissions.sort! { |a,b| a[:assignment].id - b[:assignment].id }
# assignment groups that have no points possible have to be dropped
# differently (it's a simpler case, but not one that fits in with our
# usual bisection approach)
kept = (cant_drop + submissions).any? { |s| s[:total] > 0 } ?
drop_pointed(submissions, cant_drop, keep_highest, keep_lowest) :
drop_unpointed(submissions, keep_highest, keep_lowest)
(kept + cant_drop).tap do |all_kept|
loggable_kept = all_kept.map { |s| loggable_submission(s) }
Rails.logger.info "GRADES.calculating... kept=#{loggable_kept.inspect}"
end
end
def drop_unpointed(submissions, keep_highest, keep_lowest)
sorted_submissions = submissions.sort_by { |s| s[:score] }
sorted_submissions.last(keep_highest).first(keep_lowest)
end
def drop_pointed(submissions, cant_drop, n_highest, n_lowest)
max_total = (submissions + cant_drop).map { |s| s[:total] }.max
kept = keep_highest(submissions, cant_drop, n_highest, max_total)
kept = keep_lowest(kept, cant_drop, n_lowest, max_total)
end
def keep_highest(submissions, cant_drop, keep, max_total)
keep_helper(submissions, cant_drop, keep, max_total) { |*args| big_f_best(*args) }
end
def keep_lowest(submissions, cant_drop, keep, max_total)
keep_helper(submissions, cant_drop, keep, max_total) { |*args| big_f_worst(*args) }
end
# @submissions: set of droppable submissions
# @cant_drop: submissions that are not eligible for dropping
# @keep: number of submissions to keep from +submissions+
# @max_total: the highest number of points possible
# @big_f_blk: sorting block for the big_f function
# returns +keep+ +submissions+
def keep_helper(submissions, cant_drop, keep, max_total, &big_f_blk)
return submissions if submissions.size <= keep
unpointed, pointed = (submissions + cant_drop).partition { |s|
s[:total].zero?
}
grades = pointed.map { |s| s[:score].to_f / s[:total] }.sort
q_high = estimate_q_high(pointed, unpointed, grades)
q_low = grades.first
q_mid = (q_low + q_high) / 2
x, kept = big_f_blk.call(q_mid, submissions, cant_drop, keep)
threshold = 1 / (2 * keep * 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
# bail if we can't can't ever satisfy the threshold (floats!)
break if q_mid == q_high || q_mid == q_low
x, kept = big_f_blk.call(q_mid, submissions, cant_drop, keep)
end
kept
end
def big_f(q, submissions, cant_drop, keep, &sort_blk)
kept = submissions.map { |s|
rated_score = s[:score] - q * s[:total]
[rated_score, s]
}.sort(&sort_blk).first(keep)
q_kept = kept.reduce(0) { |sum,(rated_score,_)| sum + rated_score }
q_cant_drop = cant_drop.reduce(0) { |sum,s| sum + (s[:score] - q * s[:total]) }
[q_kept + q_cant_drop, kept.map(&:last)]
end
# we can't use the student's highest grade as an upper-bound for bisection
# when 0-points-possible assignments are present, so guess the best possible
# grade the student could have earned in that case
def estimate_q_high(pointed, unpointed, grades)
if unpointed.present?
points_possible = pointed.reduce(0) { |sum,s| sum + s[:total] }
best_pointed_score = [
points_possible, # 100%
pointed.reduce(0) { |sum,s| sum + s[:score] } # ... or extra credit
].max
unpointed_score = unpointed.reduce(0) { |sum,s| sum + s[:score] }
max_score = best_pointed_score + unpointed_score
max_score.to_f / points_possible
else
grades.last
end
end
# determines the best +keep+ assignments from submissions for the given q
# (suitable for use with drop_lowest)
def big_f_best(q, submissions, cant_drop, keep)
big_f(q, submissions, cant_drop, keep) { |(a,_),(b,_)| b <=> a }
end
# determines the worst +keep+ assignments from submissions for the given q
# (suitable for use with drop_highest)
def big_f_worst(q, submissions, cant_drop, keep)
big_f(q, submissions, cant_drop, keep) { |(a,_),(b,_)| a <=> b }
end
# returns grade information from all the assignment groups
def calculate_total_from_group_scores(group_sums)
2011-02-01 09:57:29 +08:00
if @course.group_weighting_scheme == 'percent'
relevant_group_sums = group_sums.reject { |gs|
gs[:possible].zero? || gs[:possible].nil?
}
final_grade = relevant_group_sums.reduce(0) { |grade,gs|
grade + (gs[:score].to_f / gs[:possible]) * gs[:weight]
}
# scale the grade up if total weights don't add up to 100%
full_weight = relevant_group_sums.reduce(0) { |w,gs| w + gs[:weight] }
if full_weight.zero?
final_grade = nil
elsif full_weight < 100
final_grade *= 100.0 / full_weight
2011-02-01 09:57:29 +08:00
end
{:grade => final_grade.try(:round, 2)}
2011-02-01 09:57:29 +08:00
else
total, possible = group_sums.reduce([0,0]) { |(m,n),gs|
[m + gs[:score], n + gs[:possible]]
}
if possible > 0
final_grade = (total.to_f / possible) * 100
{
:grade => final_grade.round(2),
:total => total.to_f,
:possible => possible,
}
else
{:grade => nil, :total => total.to_f}
2011-02-01 09:57:29 +08:00
end
end
end
# this takes a wrapped submission (like from create_group_sums)
def loggable_submission(wrapped_submission)
{
assignment_id: wrapped_submission[:assignment].id,
score: wrapped_submission[:score],
total: wrapped_submission[:total],
}
end
end