Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
253 changes: 253 additions & 0 deletions app/models/group.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 |
Expand Down
49 changes: 49 additions & 0 deletions features/generate_faultline_strength.feature
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions features/step_definitions/generate_faultline_strength_steps.rb
Original file line number Diff line number Diff line change
@@ -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
Loading