diff --git a/app/models/group.rb b/app/models/group.rb index 307c943d..dff4ed6d 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -38,6 +38,14 @@ def calc_diversity_score ) end + def calc_faultline_strength + Group.calc_faultline_strength_for_group( + users: users.includes( :gender, :primary_language, + :cip_code, + home_state: [:home_country] ) + ) + end + def self.calc_diversity_score_for_proposed_group( emails: ) users = User.joins( :emails ).where( emails: { email: emails.split( /\s*,\s*/ ) } ) .includes( :gender, :primary_language, @@ -108,8 +116,253 @@ def self.calc_diversity_score_for_group( users: ) ds end + def self.calc_faultline_strength_for_proposed_group( emails: ) + normalized_emails = emails.split( /\s*,\s*/ ).filter_map do | email | + parsed = email.strip.downcase + parsed.presence + end.uniq + return 0.0 if normalized_emails.empty? + + users = User.joins( :emails ).where( 'LOWER(emails.email) IN (?)', normalized_emails ) + .distinct + .includes( :gender, :primary_language, + :cip_code, + home_state: [:home_country] ) + + Group.calc_faultline_strength_for_group users: + end + + def self.calc_faultline_strength_for_group( users: ) + # Faultline strength implementation follows the ASW-based approach described in: + # Keivani et al., "Team Faultline Measures: A Computational Comparison and a New + # Approach to Multiple Subgroups" (Organizational Research Methods, 2013), + # https://journals.sagepub.com/doi/10.1177/1094428113484970 + # and + # Keivani et al., "Team Faultline Measures: Rescaling the Weights of Diversity + # Attributes", https://scholarspace.manoa.hawaii.edu/server/api/core/bitstreams/9882f536-7820-4c22-9230-53d2c9f6dfb9/content + profiles = faultline_profiles_for users + return 0.0 if profiles.count < 3 + + distance_matrix = faultline_distance_matrix_for profiles + clusterings = faultline_clusterings_for distance_matrix + k_values = 2..( profiles.count - 1 ) + asw_scores = k_values.filter_map do | k | + clusters = clusterings[k] + next if clusters.nil? + + faultline_average_silhouette_width_for clusters, distance_matrix + end + + [asw_scores.max || 0.0, 0.0].max.round( 4 ) + end + private + class << self + private + + def faultline_profiles_for( users ) + now = Date.current + users.map do | user | + state = user.home_state + state = nil if state&.no_response == true + country = state&.home_country + country = nil if country&.no_response == true + + impairments = '' + impairments += user.impairment_visual ? 'v' : '' + impairments += user.impairment_auditory ? 'a' : '' + impairments += user.impairment_motor ? 'm' : '' + impairments += user.impairment_cognitive ? 'c' : '' + impairments += user.impairment_other ? 'o' : '' + impairments = 'u' if impairments.blank? + + { + categorical: { + state: state&.id, + country: country&.id, + cip_code: ( user.cip_code&.gov_code&.zero? ? nil : user.cip_code&.id ), + gender: ( user.gender.nil? || user.gender_code == '__' ? nil : user.gender.id ), + primary_language: ( user.primary_language.nil? || user.primary_language_code == '__' ? nil : user.primary_language.id ), + impairment: impairments + }, + numeric: { + age: user.date_of_birth? ? faultline_elapsed_years_for( now, user.date_of_birth ) : nil, + university_years: user.started_school? ? faultline_elapsed_years_for( now, user.started_school ) : nil + } + } + end + end + + def faultline_elapsed_years_for( current_date, past_date ) + years = current_date.year - past_date.year + anniversary_passed = current_date.month > past_date.month || + ( current_date.month == past_date.month && current_date.day >= past_date.day ) + anniversary_passed ? years : years - 1 + end + + def faultline_distance_matrix_for( profiles ) + categorical_keys = profiles.first[:categorical].keys + numeric_keys = profiles.first[:numeric].keys + categorical_weights = faultline_categorical_weights_for profiles, categorical_keys + numeric_ranges = faultline_numeric_ranges_for profiles, numeric_keys + point_count = profiles.count + + Array.new( point_count ) do | i | + Array.new( point_count ) do | j | + if i == j + 0.0 + else + faultline_distance_between_profiles( + profile_one: profiles[i], + profile_two: profiles[j], + categorical_keys:, + categorical_weights:, + numeric_keys:, + numeric_ranges: + ) + end + end + end + end + + def faultline_categorical_weights_for( profiles, keys ) + keys.to_h do | key | + values = profiles.filter_map { | profile | profile[:categorical][key] }.uniq + # Rescaling categorical diversity-attribute contribution (1 / number of + # observed categories for the attribute), following Keivani et al. + weight = values.count > 1 ? ( 1.0 / values.count ) : 0.0 + [key, weight] + end + end + + def faultline_numeric_ranges_for( profiles, keys ) + keys.to_h do | key | + values = profiles.filter_map { | profile | profile[:numeric][key] } + range = values.empty? ? 0.0 : ( values.max - values.min ).to_f + [key, range] + end + end + + def faultline_distance_between_profiles( + profile_one:, + profile_two:, + categorical_keys:, + categorical_weights:, + numeric_keys:, + numeric_ranges: + ) + distance_sum = 0.0 + weight_sum = 0.0 + + categorical_keys.each do | key | + value_one = profile_one[:categorical][key] + value_two = profile_two[:categorical][key] + next if value_one.nil? || value_two.nil? + + weight = categorical_weights[key] + next if weight.zero? + + distance_sum += ( value_one == value_two ? 0.0 : 1.0 ) * weight + weight_sum += weight + end + + numeric_keys.each do | key | + value_one = profile_one[:numeric][key] + value_two = profile_two[:numeric][key] + next if value_one.nil? || value_two.nil? + + range = numeric_ranges[key] + next if range.zero? + + distance_sum += ( value_one - value_two ).abs / range + weight_sum += 1.0 + end + + return 0.0 if weight_sum.zero? + + distance_sum / weight_sum + end + + def faultline_clusterings_for( distance_matrix ) + clusters = distance_matrix.each_index.map { | i | [i] } + clusterings = { clusters.count => clusters.map( &:dup ) } + + while clusters.count > 1 + left_cluster, right_cluster = faultline_closest_clusters_for clusters, distance_matrix + merged_cluster = left_cluster + right_cluster + clusters = ( clusters - [left_cluster, right_cluster] ) << merged_cluster + clusterings[clusters.count] = clusters.map( &:dup ) + end + + clusterings + end + + def faultline_closest_clusters_for( clusters, distance_matrix ) + best_pair = [clusters[0], clusters[1]] + best_distance = faultline_average_linkage_distance_for best_pair[0], best_pair[1], distance_matrix + + clusters.combination( 2 ) do | first_cluster, second_cluster | + distance = faultline_average_linkage_distance_for first_cluster, second_cluster, distance_matrix + next unless distance < best_distance + + best_distance = distance + best_pair = [first_cluster, second_cluster] + end + + best_pair + end + + def faultline_average_linkage_distance_for( cluster_one, cluster_two, distance_matrix ) + total_distance = 0.0 + pair_count = 0 + + cluster_one.each do | point_one | + cluster_two.each do | point_two | + total_distance += distance_matrix[point_one][point_two] + pair_count += 1 + end + end + + total_distance / pair_count + end + + def faultline_average_silhouette_width_for( clusters, distance_matrix ) + cluster_map = {} + clusters.each_with_index do | cluster, cluster_index | + cluster.each { | point | cluster_map[point] = cluster_index } + end + + silhouette_values = distance_matrix.each_index.map do | point_index | + current_cluster = clusters[cluster_map[point_index]] + faultline_silhouette_for_point point_index, current_cluster, clusters, distance_matrix + end + + silhouette_values.sum / silhouette_values.count + end + + def faultline_silhouette_for_point( point_index, current_cluster, clusters, distance_matrix ) + return 0.0 if current_cluster.count <= 1 + + within_cluster = current_cluster - [point_index] + a_value = faultline_average_distance_for point_index, within_cluster, distance_matrix + b_value = clusters.reject { | cluster | cluster.equal?( current_cluster ) } + .map { | cluster | faultline_average_distance_for point_index, cluster, distance_matrix } + .min + + denominator = [a_value, b_value].max + return 0.0 if denominator.zero? + + ( b_value - a_value ) / denominator + end + + def faultline_average_distance_for( point_index, other_points, distance_matrix ) + return 0.0 if other_points.empty? + + other_points.sum { | other_point | distance_matrix[point_index][other_point] }.to_f / other_points.count + end + end + def store_load_state @initial_member_state = '' user_ids.sort.each do | user_id | diff --git a/features/generate_faultline_strength.feature b/features/generate_faultline_strength.feature new file mode 100644 index 00000000..0985f496 --- /dev/null +++ b/features/generate_faultline_strength.feature @@ -0,0 +1,49 @@ +Feature: Generate faultline strength (ASW) + Test our ability to generate faultline strength for groups + and proposed groups using normalized email matching. + + Background: + Given a user has signed up + Given the user "has" had demographics requested + Given there is a course with an assessed project + Given the user is the instructor for the course + Given the project has a group with 4 confirmed users + + Scenario: With no demographics entered, faultline strength will be 0 + When we update the group's faultline strength + Then the group's faultline strength is 0 + + Scenario: Two aligned subgroups produce a positive faultline strength + Given the "gender" of the "first" "group" user is "m" + Given the "gender" of the "second" "group" user is "m" + Given the "gender" of the "third" "group" user is "f" + Given the "gender" of the "last" "group" user is "f" + Given the "language" of the "first" "group" user is "en" + Given the "language" of the "second" "group" user is "en" + Given the "language" of the "third" "group" user is "fr" + Given the "language" of the "last" "group" user is "fr" + Given the "cip" of the "first" "group" user is "13" + Given the "cip" of the "second" "group" user is "13" + Given the "cip" of the "third" "group" user is "47" + Given the "cip" of the "last" "group" user is "47" + Given the "first" "group" user is from "NY" in "US" + Given the "second" "group" user is from "NY" in "US" + Given the "third" "group" user is from "VT" in "US" + Given the "last" "group" user is from "VT" in "US" + Given the "dob" of the "first" "group" user is "1/1/2000" + Given the "dob" of the "second" "group" user is "1/1/2000" + Given the "dob" of the "third" "group" user is "1/1/1990" + Given the "dob" of the "last" "group" user is "1/1/1990" + Given the "uni_date" of the "first" "group" user is "1/1/2018" + Given the "uni_date" of the "second" "group" user is "1/1/2018" + Given the "uni_date" of the "third" "group" user is "1/1/2010" + Given the "uni_date" of the "last" "group" user is "1/1/2010" + When we update the group's faultline strength + Then the group's faultline strength is greater than 0.25 + + Scenario: Proposed-group faultline score handles case, spaces, and duplicate emails + Given the "gender" of the "first" "group" user is "m" + Given the "gender" of the "second" "group" user is "m" + Given the "gender" of the "third" "group" user is "f" + Given the "gender" of the "last" "group" user is "f" + Then the normalized proposed-group faultline score matches the group's users diff --git a/features/step_definitions/generate_faultline_strength_steps.rb b/features/step_definitions/generate_faultline_strength_steps.rb new file mode 100644 index 00000000..99406e1d --- /dev/null +++ b/features/step_definitions/generate_faultline_strength_steps.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +When( /^we update the group's faultline strength$/ ) do + @faultline_strength = @group.calc_faultline_strength +end + +Then( /^the group's faultline strength is (\d+(?:\.\d+)?)$/ ) do | score | + @faultline_strength.should be_within( 0.0001 ).of( score.to_f ) +end + +Then( /^the group's faultline strength is greater than (\d+(?:\.\d+)?)$/ ) do | score | + @faultline_strength.should > score.to_f +end + +Then( /^the normalized proposed-group faultline score matches the group's users$/ ) do + baseline = Group.calc_faultline_strength_for_group( users: @group.users ) + messy_emails = @users.map { | user | " #{user.email.upcase} " } + .push( @users.first.email ) + .push( ' ' ) + .join( ', ' ) + + normalized_score = Group.calc_faultline_strength_for_proposed_group( emails: messy_emails ) + normalized_score.should be_within( 0.0001 ).of( baseline ) +end diff --git a/test/models/group_test.rb b/test/models/group_test.rb index ae915076..67f14155 100644 --- a/test/models/group_test.rb +++ b/test/models/group_test.rb @@ -1,9 +1,146 @@ # frozen_string_literal: true require 'test_helper' +require 'ostruct' class GroupTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end + test 'faultline strength is zero for groups with fewer than three users' do + users = [ + faultline_user( group: :a ), + faultline_user( group: :a ) + ] + + assert_equal 0.0, Group.calc_faultline_strength_for_group( users: ) + end + + test 'faultline strength is higher for aligned subgroups than homogeneous groups' do + homogeneous_users = [ + faultline_user( group: :a ), + faultline_user( group: :a ), + faultline_user( group: :a ), + faultline_user( group: :a ) + ] + polarized_users = [ + faultline_user( group: :a ), + faultline_user( group: :a ), + faultline_user( group: :b ), + faultline_user( group: :b ) + ] + + homogeneous_strength = Group.calc_faultline_strength_for_group( users: homogeneous_users ) + polarized_strength = Group.calc_faultline_strength_for_group( users: polarized_users ) + + assert_in_delta 0.0, homogeneous_strength, 0.0001 + assert_operator polarized_strength, :>, homogeneous_strength + assert_operator polarized_strength, :>=, 0.25 + end + + test 'faultline strength handles nil demographics without delegation errors' do + users = [ + faultline_user_without_demographics, + faultline_user_without_demographics, + faultline_user_without_demographics, + faultline_user_without_demographics + ] + + assert_nothing_raised do + assert_in_delta 0.0, Group.calc_faultline_strength_for_group( users: ), 0.0001 + end + end + + test 'proposed-group faultline strength deduplicates users by email join' do + relation = MockFaultlineUserRelation.new + captured_users = nil + + User.stub :joins, relation do + Group.stub :calc_faultline_strength_for_group, ->( users: ) { captured_users = users; 0.42 } do + result = Group.calc_faultline_strength_for_proposed_group( + emails: ' A@example.com, a@example.com, , B@example.com ' + ) + + assert_equal 0.42, result + end + end + + assert_equal 'LOWER(emails.email) IN (?)', relation.where_clause + assert_equal %w[a@example.com b@example.com], relation.where_emails + assert relation.distinct_called + assert_same relation, captured_users + end + + test 'proposed-group faultline strength returns zero when no valid emails are provided' do + assert_equal 0.0, Group.calc_faultline_strength_for_proposed_group( emails: ' , , ' ) + end + + private + + class MockFaultlineUserRelation + attr_reader :distinct_called, :where_clause, :where_emails + + def where( clause, emails ) + @where_clause = clause + @where_emails = emails + self + end + + def distinct + @distinct_called = true + self + end + + def includes( *_args ) + self + end + end + + def faultline_user( group: ) + country_id = ( group == :a ? 1 : 2 ) + state_id = ( group == :a ? 11 : 22 ) + gender_id = ( group == :a ? 101 : 202 ) + language_id = ( group == :a ? 301 : 302 ) + cip_id = ( group == :a ? 401 : 402 ) + cip_gov_code = ( group == :a ? 11_0101 : 26_0101 ) + started_school_year = ( group == :a ? 2023 : 2018 ) + birth_year = ( group == :a ? 2004 : 1997 ) + + OpenStruct.new( + home_state: OpenStruct.new( + id: state_id, + home_country: OpenStruct.new( id: country_id, no_response: false ) + ), + home_state_no_response: false, + cip_code: OpenStruct.new( id: cip_id, gov_code: cip_gov_code ), + gender: OpenStruct.new( id: gender_id ), + gender_code: ( group == :a ? 'M' : 'F' ), + primary_language: OpenStruct.new( id: language_id ), + primary_language_code: ( group == :a ? 'en' : 'fr' ), + impairment_visual: ( group == :b ), + impairment_auditory: false, + impairment_motor: false, + impairment_cognitive: false, + impairment_other: false, + date_of_birth: Date.new( birth_year, 1, 1 ), + started_school: Date.new( started_school_year, 1, 1 ), + date_of_birth?: true, + started_school?: true + ) + end + + def faultline_user_without_demographics + OpenStruct.new( + home_state: nil, + cip_code: nil, + gender: nil, + primary_language: nil, + impairment_visual: false, + impairment_auditory: false, + impairment_motor: false, + impairment_cognitive: false, + impairment_other: false, + date_of_birth: nil, + started_school: nil, + date_of_birth?: false, + started_school?: false + ) + end end