diff --git a/frontend/src/lib/utils/matching/LikertQuestion.ts b/frontend/src/lib/utils/matching/LikertQuestion.ts index 09d8c39e6..679dd8461 100644 --- a/frontend/src/lib/utils/matching/LikertQuestion.ts +++ b/frontend/src/lib/utils/matching/LikertQuestion.ts @@ -1,20 +1,26 @@ -import {MultipleChoiceQuestion} from '$voter/vaa-matching'; +import type {CoordinateOrMissing, Id} from 'vaa-shared'; +import {OrdinalQuestion} from '$voter/vaa-matching'; /** * A dummy question object for matching. */ -export class LikertQuestion extends MultipleChoiceQuestion { +export class LikertQuestion extends OrdinalQuestion { public readonly category: QuestionCategoryProps; constructor({id, values, category}: LikertQuestionOptions) { - super(id, values); + super({id, values}); this.category = category; } + + normalizeValue(value: number): CoordinateOrMissing { + // The current frontend implemenation of questions uses numbers for choice keys + return super.normalizeValue(`${value}`); + } } /** * Options for a dummy question object for matching. */ interface LikertQuestionOptions { - id: ConstructorParameters[0]; - values: ConstructorParameters[1]; + id: Id; + values: ConstructorParameters[0]['values']; category: QuestionCategoryProps; } diff --git a/frontend/src/lib/utils/matching/imputePartyAnswers.ts b/frontend/src/lib/utils/matching/imputePartyAnswers.ts index 883fc29ed..11e38f759 100644 --- a/frontend/src/lib/utils/matching/imputePartyAnswers.ts +++ b/frontend/src/lib/utils/matching/imputePartyAnswers.ts @@ -1,5 +1,5 @@ import {error} from '@sveltejs/kit'; -import {MISSING_VALUE} from '$voter/vaa-matching'; +import {MISSING_VALUE} from 'vaa-shared'; import {logDebugError} from '$lib/utils/logger'; import {mean} from './mean'; import {median} from './median'; diff --git a/frontend/src/lib/utils/matching/match.ts b/frontend/src/lib/utils/matching/match.ts index daa1b38e9..c431a43cc 100644 --- a/frontend/src/lib/utils/matching/match.ts +++ b/frontend/src/lib/utils/matching/match.ts @@ -7,8 +7,8 @@ import {error} from '@sveltejs/kit'; import { type MatchingOptions, MatchingAlgorithm, - DistanceMetric, - MissingValueDistanceMethod, + DISTANCE_METRIC, + MISSING_VALUE_METHOD, type MatchableQuestionGroup } from '$voter/vaa-matching'; import {LikertQuestion} from './LikertQuestion'; @@ -33,9 +33,9 @@ export async function match( ): Promise[]> { // Create the algorithm instance const algorithm = new MatchingAlgorithm({ - distanceMetric: DistanceMetric.Manhattan, + distanceMetric: DISTANCE_METRIC.Manhattan, missingValueOptions: { - missingValueMethod: MissingValueDistanceMethod.AbsoluteMaximum + method: MISSING_VALUE_METHOD.RelativeMaximum } }); @@ -51,7 +51,7 @@ export async function match( questions.push( new LikertQuestion({ id: q.id, - values: q.values.map((o) => ({value: o.key})), + values: q.values.map((o) => ({id: `${o.key}`, value: o.key})), category: q.category }) ); @@ -85,7 +85,12 @@ export async function match( } // Perform the matching - return algorithm.match(questions, voter, entities, matchingOptions); + return algorithm.match({ + questions, + reference: voter, + targets: entities, + options: matchingOptions + }); } /** diff --git a/frontend/src/lib/utils/tests/matching.test.ts b/frontend/src/lib/utils/tests/matching.test.ts index be22a8cac..cc0ae3bd7 100644 --- a/frontend/src/lib/utils/tests/matching.test.ts +++ b/frontend/src/lib/utils/tests/matching.test.ts @@ -1,7 +1,7 @@ import {expect, test} from 'vitest'; import {imputePartyAnswers, mean, median} from '../matching'; import {MockCandidate, MockParty} from './mock-objects'; -import {MISSING_VALUE} from '$voter/vaa-matching'; +import {MISSING_VALUE} from 'vaa-shared'; test('Mean and median', () => { expect(mean([1, 2, 2, 2, 10]), 'Mean').toEqual((1 + 2 + 2 + 2 + 10) / 5); diff --git a/frontend/src/lib/voter/vaa-matching/README.md b/frontend/src/lib/voter/vaa-matching/README.md index 257d2edf0..97b63c3ff 100644 --- a/frontend/src/lib/voter/vaa-matching/README.md +++ b/frontend/src/lib/voter/vaa-matching/README.md @@ -1,13 +1,19 @@ # Matching algorithms (`vaa-matching`) +## Dependencies + +`vaa-shared`: Definitions related to matching space distances, matchable questions and entities having answers to these are shared between this and other `vaa` modules. + ## Quick start -1. Create question objects that implement [`MatchableQuestion`](./src/question/matchableQuestion.ts) -2. Create candidate objects and a voter passing that implement [`HasMatchableAnswers`](./src/answer/hasMatchableAnswers.ts). With regard to the matching algorithm, there's no difference between the voter and candidates -3. Instantiate a [`MatchingAlgorithm`](./src/algorithms/matchingAlgorithm.ts) object with suitable options -4. Call the algorithm's [`match`](./src/algorithms/matchingAlgorithm.ts) method passing as arguments the questions to match for, the voter and the candidates as well as optional [`MatchingOptions`](./src/algorithms/matchingAlgorithm.type.ts). The algorithm returns an array of [`Match`](./src/match/match.ts) objects for each candidate. - 1. You can get submatches for question categories by supplying `questionGroups` in the options. These are objects implementing [`MatchableQuestionGroup`](./src/question/matchableQuestionGroup.ts). The submatches are contained in the [`subMatches`](./src/match/subMatch.ts) array of the [`Match`](./src/match/match.ts) objects. - 2. Note that only those questions that the voter has answered will be considered in the matching. +1. Create question objects that implement [`MatchableQuestion`](/shared/src/matching/matchableQuestion.type.ts) +2. Create candidate objects and a voter that implement [`HasAnswers`](/shared/src/matching/hasAnswers.type.ts). With regard to the matching algorithm, there’s no difference between the voter and candidates +3. Instantiate a [`MatchingAlgorithm`](./src/algorithms/matchingAlgorithm.ts) with suitable options +4. Call the algorithm’s [`match`](./src/algorithms/matchingAlgorithm.ts) method passing as arguments the questions to match for, the voter, the candidates and optional [`MatchingOptions`](./src/algorithms/matchingAlgorithm.type.ts). The algorithm returns an array of [`Match`](./src/match/match.ts) objects for each candidate ordered by ascending distance + 1. The `Match` objects contain a rererence to the `entity` they target + 2. You can get submatches for question categories by supplying `questionGroups` in the options. These are objects implementing [`MatchableQuestionGroup`](./src/question/matchableQuestionGroup.ts). The submatches are contained in the [`subMatches`](./src/match/subMatch.ts) array of the [`Match`](./src/match/match.ts) objects + 3. You can weight the questions by supplying `questionWeights` in the options + 4. Only those questions that the voter has answered will be considered in the matching ## Trying it out @@ -23,7 +29,7 @@ To that end, the module is built following these principles: 1. Enable mixing of different kinds of questions 2. Provide flexible methods of dealing with missing values -3. Do not expect question values to fall into a specific numeric range by default +3. Do not expect answers to questions to be inherently numeric 4. Prefer reliability even at the cost of performance 5. Prefer verbosity if it can help avoid confusion @@ -31,35 +37,40 @@ To that end, the module is built following these principles: The general operational paradigm of the algorithm is to treat the voter and the candidate as positions in a multidimensional space. To compute a match we measure the distance between the two in that space and return a value, which is normalized so that it represents the fraction of the maximum possible distance in that space, i.e. 0–100 %. -The algorithm also supports a more complicated method of matching, in which the positions are project to a lower-dimensional space before calculating the distance. This method is used often to place the persons into a two or three-dimensional space with axes such as 'Economical left–right' and 'Value conservative–liberal'. In this kind of projection, different weights are given to the dimensions in source space (that is, the questions) in calculating the positions in the target space. In the basic method, on the contrary, all of the questions have equal weights in calculating the distance. +The algorithm also supports a more complicated method of matching, in which the positions are project to a lower-dimensional space before calculating the distance. This method is used often to place the persons into a two or three-dimensional space with axes such as ‘Economical left–right’ and ‘Value conservative–liberal’. In this kind of projection, different weights are given to the dimensions in source space (that is, the questions) in calculating the positions in the target space. In the basic method, on the contrary, all of the questions have equal weights in calculating the distance. + +In order to do this, you need to supply a [`MatchingSpaceProjector`](./src/algorithms/matchingSpaceProjector.ts) when instantiating the algorithm. ### Process -The process of computing a match goes roughly as follows. (This is a simplified example that does not use subcategory matching or projection into a lower-dimensional space.) +The process of computing a match goes roughly as follows. (This is a simplified example that does not use subcategory matching, question weighting or projection into a lower-dimensional space.) -1. [`MatchingAlgorithm.match()`](./src/algorithms/matchingAlgorithm.ts) is called and passed an array of [`MatchableQuestion`s](./src/question/matchableQuestion.ts)[^1], a `Voter` and an array of `Candidates`, which we'll refer to as `Entities` below. Both of these must implement the [`HasMatchableAnswers`](./src/answer/hasMatchableAnswers.ts) interface. -2. The `Voter` is queried for the questions they have answered by calling the getter `Voter.answers` and the questions they have not answered are removed from matching. If no questions remain, an error will be thrown. -3. Before using the actual answers, we create the [`MatchingSpace`](./src/space/matchingSpace.ts) in which we position the `Entities`. Usually this is simply a space with `N` dimensions of equal weight, where `N` is the number of questions.[^2] -4. To place them in the space, we iterate over each `Entity` and over each [`MatchableQuestion`](./question/mathcableQuestion.ts). - - The position is represented by an `N`-length array of numbers ([`MatchingSpacePosition`](./src/space/position.ts)). - - We build that get by getting the `Entity`'s answer to each question by querying their `answers` record with the question id. - - The values we have are still of an unknown type, so we pass them to the question's [`normalizeValue()`](./src/question/matchableQuestion.ts) method, which returns either a [`MatchingSpaceCoordinate`](./src/space/matchingSpace.ts) (`number` in range 0–1 or `undefined`).[^3] -5. Now that the `Entities` are positioned in a normalized space, we can finally calculate the matches, that is, the normalized distances of the `Candidates` to the `Voter`. -6. To do that, we iterate over each `Entity` and calculate their distance to the `Voter` in each dimension of the [`MatchingSpace`](./src/space/matchingSpace.ts), sum these up and take their average.[^4] - - Recall that some coordinates of the `Candidates`' positions may be `undefined`.[^5] To calculate distances between these and the `Voter`'s respective coordinates, we need to impute a value in place of the missing one.[^6] -7. Finally, we use the distances to create a [`Match`](./src/match/match.ts) object for each of the `Candidates`, which includes a reference to the `Candidate` and the distance. The [`Matches`](./src/match/match.ts) also contain getters for presenting the distance as a percentage score etc.[^7] -8. The [`Matches`](./src/match/match.ts) are returned and may be passed on to UI components. They are ordered by ascending match distance. +1. [`MatchingAlgorithm.match()`](./src/algorithms/matchingAlgorithm.ts) is called and passed an array of [`MatchableQuestion`](/shared/src/matching/matchableQuestion.type.ts)[^1], a `reference` (usually the voter) and a `targets` array (usually the candidates or parties), which we’ll refer to as entities below. All of these must implement the [`HasAnswers`](/shared/src/matching/hasAnswers.type.ts) interface. [Options](./src/algorithms/matchingAlgorithm.type.ts) may also be provided. +2. The `reference` is queried for the questions they have answered by calling the [`answers`](/shared/src/matching/hasAnswers.type.ts) getter. The questions they have not answered are removed from matching. If no questions remain, an error will be thrown. +3. Before using the actual answers, we create the [`MatchingSpace`](./src/space/matchingSpace.ts) in which we position the entities. Usually this is simply a space with `N` dimensions of equal weight, where `N` is the number of questions.[^2] +4. To place them in the space, we iterate over each entity and over each question. + - The position is represented by an `N`-length array of numbers ([`Position`](./src/space/position.ts)) with possible subdimensions. + - We build that get by getting the entity’s answer to each question by querying their [`answers`](/shared/src/matching/hasAnswers.type.ts) record with the question [id](/shared/src/matching/id.type.ts). + - The values we have are still of an unknown type, so we pass them to the question’s [`normalizeValue(value: unknown)`](/shared/src/matching/matchableQuestion.ts) method, which returns either a [`CoordinateOrMissing`](/shared/src/matching/distance.type.ts) (`number` or `undefined`) or an array of these.[^3] +5. Now that the entities are positioned in a normalized space, we can finally calculate the matches, that is, the normalized distances of the `targets` to the `reference`. +6. To do that, we iterate over each `target` and calculate their distance to the `reference` in each dimension of the [`MatchingSpace`](./src/space/matchingSpace.ts), sum these up and normalise.[^4] + - Recall that some coordinates of the `targets`’ positions may be `undefined`. To calculate distances between these and the `reference`’s respective coordinates, we need to impute a value in place of the missing one.[^5] +7. Finally, we use the distances to create a [`Match`](./src/match/match.ts) object for each of the `targets`, which includes a reference to the `target` entity and the distance. The [`Match`es](./src/match/match.ts) also contain getters for presenting the distance as, e.g., a percentage score.[^6] +8. The [`Match`es](./src/match/match.ts) are returned and may be passed on to UI components. They are ordered by ascending match distance. -[^1]: The questions have to implement a [`normalizeValue()`](./src/question/matchableQuestion.ts) method, because otherwise we don't know how to compare the answer values, whose type is unspecified, to each other. +## Future developments -[^2]: Some question types, such as ranked preference questions, create multiple subdimensions with a total weight of 1. +1. Distances are now measured using a combinations of [`kernels`](./src/distance/metric.ts) and other helper functions. It seems likely that we could simplify all of these to simple distance measurements in a vector space. +2. Provide an example implementation of a ranked preference question, which involves creating `f(n) / (2 * f(n-2))` subdimensions where `n` is the number of options and `f(•)` the factorial to map each pairwise preference. + +[^1]: The questions have to implement a [`normalizeValue()`](/shared/src/matching/matchableQuestion.type.ts) method, because otherwise we don’t know how to compare the answer values, whose type is unspecified, to each other. -[^3]: Actually, [`normalizeValue()`](./src/question/matchableQuestion.ts) can also return an array of numbers if the question is such that creates multiple subdimensions. +[^2]: Some question types, such as ranked preference questions, create multiple subdimensions with a total weight of 1. -[^4]: To be precise, it's the average weighted by the weights of the dimensions, which may differ from 1 in case of questions creating subdimensions. +[^3]: [`normalizeValue()`](/shared/src/matching/matchableQuestion.ts) returns an array of numbers if the question is one that creates multiple subdimensions, such as a [`CategoricalQuestion`](./src/question/categoricalQuestion.ts) -[^5]: The `Voter`'s coordinates cannot be `undefined`. +[^4]: To be precise, it’s the average weighted by the weights of the dimensions, which may differ from 1 in case of questions creating subdimensions. -[^6]: There are several methods to this, which are defined in the constructor options to the [`MatchingAlgorithm`](./src/algorithms/matchingAlgorithm.ts). +[^5]: There are several methods to this, which are defined in the constructor options to the [`MatchingAlgorithm`](./src/algorithms/matchingAlgorithm.ts). -[^7]: And in case of subcategory matching, they also contain [`subMatches`](./src/match/subMatch.ts) for each category. +[^6]: And in case of subcategory matching, they also contain [`subMatches`](./src/match/subMatch.ts) for each category. diff --git a/frontend/src/lib/voter/vaa-matching/examples/example.ts b/frontend/src/lib/voter/vaa-matching/examples/example.ts index 3e75109f9..3e531fac0 100644 --- a/frontend/src/lib/voter/vaa-matching/examples/example.ts +++ b/frontend/src/lib/voter/vaa-matching/examples/example.ts @@ -1,14 +1,12 @@ +import type {AnswerDict, HasAnswers, MatchableQuestion} from 'vaa-shared'; import { - DistanceMetric, + DISTANCE_METRIC, MatchingAlgorithm, - MissingValueDistanceMethod, - MultipleChoiceQuestion, - type HasMatchableAnswers, - type MatchableQuestion, + MISSING_VALUE_METHOD, + OrdinalQuestion, type MatchableQuestionGroup, type MatchingOptions } from '..'; -import type {AnswerDict} from '../src/entity/hasMatchableAnswers'; /** * Simple example. @@ -25,7 +23,7 @@ function main( ): void { // Create dummy questions const questions = Array.from({length: numQuestions}, (i: number) => - MultipleChoiceQuestion.fromLikert(`q${i}`, likertScale) + OrdinalQuestion.fromLikert({id: `q${i}`, scale: likertScale}) ); // Create answer subgroup @@ -54,23 +52,24 @@ function main( // Manhattan matching algorithm const manhattan = new MatchingAlgorithm({ - distanceMetric: DistanceMetric.Manhattan, + distanceMetric: DISTANCE_METRIC.Manhattan, missingValueOptions: { - missingValueMethod: MissingValueDistanceMethod.AbsoluteMaximum + method: MISSING_VALUE_METHOD.RelativeMaximum } }); // Directional matching algorithm const directional = new MatchingAlgorithm({ - distanceMetric: DistanceMetric.Directional, + distanceMetric: DISTANCE_METRIC.Directional, missingValueOptions: { - missingValueMethod: MissingValueDistanceMethod.AbsoluteMaximum + method: MISSING_VALUE_METHOD.RelativeMaximum } }); // Get matches for both methods - const manhattanMatches = manhattan.match(questions, voter, candidates, matchingOptions); - const directionalMatches = directional.match(questions, voter, candidates, matchingOptions); + const args = {questions, reference: voter, targets: candidates, options: matchingOptions}; + const manhattanMatches = manhattan.match(args); + const directionalMatches = directional.match(args); // Generate output string let output = `Questions: ${numQuestions} • Likert scale ${likertScale}\n`; @@ -93,7 +92,7 @@ function main( * @returns Answer dict */ function createAnswers( - questions: MatchableQuestion[], + questions: Array, answerValue: number | ((index: number) => number), missing = 0 ): AnswerDict { @@ -110,7 +109,7 @@ function createAnswers( /** * A dummy candidate object for matching. */ -class Candidate implements HasMatchableAnswers { +class Candidate implements HasAnswers { constructor( public readonly name: string, public answers: AnswerDict diff --git a/frontend/src/lib/voter/vaa-matching/index.ts b/frontend/src/lib/voter/vaa-matching/index.ts index a0fe4b076..7efba0438 100644 --- a/frontend/src/lib/voter/vaa-matching/index.ts +++ b/frontend/src/lib/voter/vaa-matching/index.ts @@ -4,7 +4,6 @@ */ export * from './src/algorithms'; -export * from './src/entity'; export * from './src/distance'; export * from './src/match'; export * from './src/missingValue'; diff --git a/frontend/src/lib/voter/vaa-matching/src/algorithms/matchingAlgorithm.ts b/frontend/src/lib/voter/vaa-matching/src/algorithms/matchingAlgorithm.ts index 38317ad4b..46161e15c 100644 --- a/frontend/src/lib/voter/vaa-matching/src/algorithms/matchingAlgorithm.ts +++ b/frontend/src/lib/voter/vaa-matching/src/algorithms/matchingAlgorithm.ts @@ -1,44 +1,32 @@ -import type {HasMatchableAnswers} from '../entity'; -import {measureDistance, DistanceMetric} from '../distance'; +import {MISSING_VALUE, type HasAnswers, type Id, type MatchableQuestion} from 'vaa-shared'; +import {measureDistance, type DistanceMetric} from '../distance'; import {Match, SubMatch} from '../match'; -import {MISSING_VALUE, type MissingValueImputationOptions} from '../missingValue'; -import { - createSubspace, - MatchingSpace, - MatchingSpacePosition, - type MatchingSpaceCoordinate -} from '../space'; -import type {MatchableQuestion, MatchableQuestionGroup} from '../question'; +import type {MissingValueImputationOptions} from '../missingValue'; +import {createSubspace, MatchingSpace, Position} from '../space'; +import type {MatchableQuestionGroup} from '../question'; import type {MatchingSpaceProjector} from './matchingSpaceProjector'; import type {MatchingAlgorithmOptions, MatchingOptions} from './matchingAlgorithm.type'; /** - * Base class for matching algorithms. With different constructor options - * most basic matching types can be implemented. - * + * Base class for matching algorithms. With different constructor options most basic matching types can be implemented. * The matching logic is as follows: - * 1. Project all the answers into a normalized MatchingSpace where all - * dimensions range from [-.5, .5] (the range is defined by - * NORMALIZED_DISTANCE_EXTENT and centered around zero) + * 1. Project all the answers into a normalized `MatchingSpace` where all dimensions conform with `Coordinate`. * 2. Possibly reproject the positions to a low-dimensional space - * 3. Measure distances in this space using measureDistance + * 3. Measure distances in this space using `measureDistance` */ export class MatchingAlgorithm { /** The distance metric to use. */ distanceMetric: DistanceMetric; - /** Options passed to imputeMissingValues */ + /** Options passed to imputeMissingValue */ missingValueOptions: MissingValueImputationOptions; - /** A possible projector that will convert the results from one - * matching space to another, usually lower-dimensional, one. */ + /** A possible projector that will convert the results from one matching space to another, usually lower-dimensional, one. */ projector?: MatchingSpaceProjector; /** * Create a new MatchingAlgorithm. - * - * @param distanceMetric The metric to use for distance calculations, e.g. DistanceMetric.Manhattan. + * @param distanceMetric The metric to use for distance calculations, e.g. `DistanceMetric.Manhattan`. * @param missingValueOptions The options to use for imputing missing values - * @param projector An optional projector that will project the results from one matching space to another, - * usually lower-dimensional one + * @param projector An optional projector that will project the results from one matching space to another, usually lower-dimensional one */ constructor({distanceMetric, missingValueOptions, projector}: MatchingAlgorithmOptions) { this.distanceMetric = distanceMetric; @@ -47,74 +35,92 @@ export class MatchingAlgorithm { } /** - * Calculate matches between the referenceEntity and the other entities. - * - * @param questions The questions to include in the matching. Note that only those of the questions that the referenceEntity has answered will be included in the matching. - * @param referenceEntity The entity to match against, e.g. voter - * @param entities The entities to match with, e.g. candidates + * Calculate matches between the reference and the other targets. + * @param questions The questions to include in the matching. Note that only those of the questions that the reference has answered will be included in the matching. + * @param reference The entity to match against, e.g. voter + * @param targets The targets to match with, e.g. candidates * @options Matching options, see. `MatchingOptions`. * @returns An array of Match objects */ - match( - questions: MatchableQuestion[], - referenceEntity: HasMatchableAnswers, - entities: readonly E[], - options: MatchingOptions = {} - ): Match[] { + match< + TTarget extends HasAnswers, + TGroup extends MatchableQuestionGroup = MatchableQuestionGroup + >({ + questions, + reference, + targets, + options + }: { + questions: ReadonlyArray; + reference: HasAnswers; + targets: ReadonlyArray; + options?: MatchingOptions; + }): Array> { if (questions.length === 0) throw new Error('Questions must not be empty'); - if (entities.length === 0) throw new Error('Entities must not be empty'); + if (targets.length === 0) throw new Error('Targets must not be empty'); + options ??= {}; // Check that questions contain no duplicate ids const ids = new Set(); for (const q of questions) { if (ids.has(q.id)) throw new Error(`Duplicate question id: ${q.id}`); ids.add(q.id); } - // Filter questions so that only those that the referenceEntity has answers for are included + // Filter questions so that only those that the reference has answers for are included questions = questions.filter((q) => { - const v = referenceEntity.answers[q.id]?.value; + const v = reference.answers[q.id]?.value; return v !== undefined && v !== MISSING_VALUE; }); - if (questions.length === 0) - throw new Error('ReferenceEntity has no answers matching the questions'); - // NB. we add the referenceEntity to the entities to project as well as in the options - let positions = this.projectToNormalizedSpace(questions, [referenceEntity, ...entities]); + if (questions.length === 0) throw new Error('Reference has no answers matching the questions'); + let positions = this.projectToNormalizedSpace({ + questions, + questionWeights: options.questionWeights, + targets: [reference, ...targets] // Add the reference to the targets to project + }); // Possibly project to a low-dimensional space if (this.projector) positions = this.projector.project(positions); // We need the referencePosition and space for distance measurement const referencePosition = positions.shift(); if (!referencePosition) throw new Error('Reference position is undefined!'); // Create possible matching subspaces for, e.g., category matches - let subspaces: MatchingSpace[] = []; + let subspaces = new Array(); if (options.questionGroups) { subspaces = options.questionGroups.map((g) => - createSubspace(questions, g.matchableQuestions) + createSubspace({questions, subset: g.matchableQuestions}) ); } // Calculate matches const measurementOptions = { metric: this.distanceMetric, - missingValueOptions: this.missingValueOptions + missingValueOptions: { + ...this.missingValueOptions + } }; - const matches: Match[] = []; - for (let i = 0; i < entities.length; i++) { + const matches = new Array>(); + for (let i = 0; i < targets.length; i++) { + const entity = targets[i]; if (options.questionGroups) { - const distances = measureDistance( - referencePosition, - positions[i], - measurementOptions, + const distances = measureDistance({ + reference: referencePosition, + target: positions[i], + options: measurementOptions, subspaces - ); - const submatches = distances.subspaces.map((dist, k) => { - if (options.questionGroups?.[k] == null) + }); + const subMatches = distances.subspaces.map((distance, k) => { + const questionGroup = options.questionGroups?.[k]; + if (questionGroup == null) throw new Error( "Distances returned by measureDistance don't match the number of questionGroups!" ); - return new SubMatch(dist, options.questionGroups[k]); + return new SubMatch({distance, questionGroup}); }); - matches.push(new Match(distances.global, entities[i], submatches)); + matches.push(new Match({distance: distances.global, entity, subMatches})); } else { - const distance = measureDistance(referencePosition, positions[i], measurementOptions); - matches.push(new Match(distance, entities[i])); + const distance = measureDistance({ + reference: referencePosition, + target: positions[i], + options: measurementOptions + }); + matches.push(new Match({distance, entity})); } } // Sort by ascending distance @@ -123,36 +129,31 @@ export class MatchingAlgorithm { } /** - * Project entities into a normalized MatchingSpace, where distances can be calculated. - * + * Project targets into a normalized `MatchingSpace`, where distances can be calculated. * @param questions The list of questions to use for distance calculations - * @param entities The entities to project - * @returns An array of positions in the normalized MatchingSpace + * @param targets The targets to project + * @returns An array of positions in the normalized `MatchingSpace` */ - projectToNormalizedSpace( - questions: readonly MatchableQuestion[], - entities: readonly HasMatchableAnswers[] - ): MatchingSpacePosition[] { + projectToNormalizedSpace({ + questions, + targets, + questionWeights + }: { + questions: ReadonlyArray; + targets: ReadonlyArray; + questionWeights?: Record; + }): Array { if (questions.length === 0) throw new Error('Questions must not be empty'); - if (entities.length === 0) throw new Error('Entities must not be empty'); + if (targets.length === 0) throw new Error('Targets must not be empty'); // Create MatchingSpace - const dimensionWeights: number[] = []; - for (const question of questions) { - const dims = question.normalizedDimensions ?? 1; - dimensionWeights.push(...Array.from({length: dims}, () => 1 / dims)); - } - const space = new MatchingSpace(dimensionWeights); + const space = MatchingSpace.fromQuestions({questions, questionWeights}); // Create positions - const positions: MatchingSpacePosition[] = []; - for (const entity of entities) { - const coords: MatchingSpaceCoordinate[] = []; - for (const question of questions) { - const value = question.normalizeValue(entity.answers[question.id]?.value ?? MISSING_VALUE); - // We need this check for preference order questions, which return a list of subdimension distances - if (Array.isArray(value)) coords.push(...value); - else coords.push(value); - } - positions.push(new MatchingSpacePosition(coords, space)); + const positions = new Array(targets.length); + for (let i = 0; i < targets.length; i++) { + const coordinates = questions.map((question) => + question.normalizeValue(targets[i].answers[question.id]?.value ?? MISSING_VALUE) + ); + positions[i] = new Position({coordinates, space}); } return positions; } diff --git a/frontend/src/lib/voter/vaa-matching/src/algorithms/matchingAlgorithm.type.ts b/frontend/src/lib/voter/vaa-matching/src/algorithms/matchingAlgorithm.type.ts index 107bbee0e..1bb3e4174 100644 --- a/frontend/src/lib/voter/vaa-matching/src/algorithms/matchingAlgorithm.type.ts +++ b/frontend/src/lib/voter/vaa-matching/src/algorithms/matchingAlgorithm.type.ts @@ -1,3 +1,4 @@ +import type {Id} from 'vaa-shared'; import type {DistanceMetric} from '../distance'; import type {MissingValueImputationOptions} from '../missingValue'; import type {MatchingSpaceProjector} from './matchingSpaceProjector'; @@ -9,21 +10,25 @@ import type {MatchableQuestionGroup} from '../question'; export interface MatchingAlgorithmOptions { /** The distance metric to use. */ distanceMetric: DistanceMetric; - /** Options passed to imputeMissingValues */ + /** Options passed to imputeMissingValue */ missingValueOptions: MissingValueImputationOptions; - /** A possible projector that will convert the results from one - * matching space to another, usually lower-dimensional, one. */ + /** A possible projector that will convert the results from one matching space to another, usually lower-dimensional, one. */ projector?: MatchingSpaceProjector; } /** * Options passed to the match method of a MatchingAlgorithm */ -export interface MatchingOptions { - /** A list of question subgroups or categories in which distances are also - * measured. Note that if these subgroups have no overlap with the the - * `referenceEntity`'s question (or `questionList`) passed to `match`, - * the `SubMatches` for them will have a score of zero but no error will - * be thrown. */ - questionGroups?: T[]; +export interface MatchingOptions { + /** + * A list of question subgroups or categories in which distances are also measured. + * Note that if these subgroups have no overlap with the the `referenceEntity`'s question (or `questionList`) passed to `match`, the `SubMatches` distance for them will have a score of `COORDINATE.Extent / 2` but no error will be thrown. + * Note also that any weights assigned to the questions in the full set will be disregarded for these subgroups. + */ + questionGroups?: Array; + /** + * A full or partial record of question weights by id for calculating the overall distance. If not specified at all or a given question, the weights default to 1. + * Note that the weights are not applied to the subgroups matches. + */ + questionWeights?: Record; } diff --git a/frontend/src/lib/voter/vaa-matching/src/algorithms/matchingSpaceProjector.ts b/frontend/src/lib/voter/vaa-matching/src/algorithms/matchingSpaceProjector.ts index 525258c4a..89c0c1141 100644 --- a/frontend/src/lib/voter/vaa-matching/src/algorithms/matchingSpaceProjector.ts +++ b/frontend/src/lib/voter/vaa-matching/src/algorithms/matchingSpaceProjector.ts @@ -1,4 +1,4 @@ -import type {MatchingSpacePosition} from '../space'; +import type {Position} from '../space'; /** * For future implementation. @@ -6,5 +6,5 @@ import type {MatchingSpacePosition} from '../space'; * by using a weight matrix. */ export interface MatchingSpaceProjector { - project(position: MatchingSpacePosition[]): MatchingSpacePosition[]; + project(position: ReadonlyArray): Array; } diff --git a/frontend/src/lib/voter/vaa-matching/src/distance/assert.ts b/frontend/src/lib/voter/vaa-matching/src/distance/assert.ts deleted file mode 100644 index 8f26ca8f9..000000000 --- a/frontend/src/lib/voter/vaa-matching/src/distance/assert.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { - NORMALIZED_DISTANCE_EXTENT, - type SignedNormalizedDistance, - type UnsignedNormalizedDistance -} from './distance'; - -/** - * Assert that `value` is a `SignedNormalizedDistance` within the correct range. - * - * @param value The number to test - * @returns True if the value is a `SignedNormalizedDistance` - * @throws If the value is not a `SignedNormalizedDistance` - */ -export function assertSignedNormalized( - value: SignedNormalizedDistance -): value is SignedNormalizedDistance { - if ( - isNaN(value) || - value < -NORMALIZED_DISTANCE_EXTENT / 2 || - value > NORMALIZED_DISTANCE_EXTENT / 2 - ) - throw new Error(`${value} is not a SignedNormalizedDistance!`); - return true; -} - -/** - * Assert that `value` is a `UnsignedNormalizedDistance` within the correct range. - * - * @param value The number to test - * @returns True if the value is a `UnsignedNormalizedDistance` - * @throws If the value is not a `UnsignedNormalizedDistance` - */ -export function assertUnsignedNormalized( - value: UnsignedNormalizedDistance -): value is UnsignedNormalizedDistance { - if (isNaN(value) || value < 0 || value > NORMALIZED_DISTANCE_EXTENT) - throw new Error(`${value} is not a UnsignedNormalizedDistance!`); - return true; -} diff --git a/frontend/src/lib/voter/vaa-matching/src/distance/distance.ts b/frontend/src/lib/voter/vaa-matching/src/distance/distance.ts deleted file mode 100644 index 4057e1366..000000000 --- a/frontend/src/lib/voter/vaa-matching/src/distance/distance.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Defines the length of the normalized value space. - */ -export const NORMALIZED_DISTANCE_EXTENT = 1; - -/** - * Should be a number [-0.5, 0.5 (NORMALIZED_DISTANCE_EXTENT / 2)], but we cannot easily enforce this. - */ -export type SignedNormalizedDistance = number; - -/** - * Should be a number [0, 1 (NORMALIZED_DISTANCE_EXTENT)], but we cannot easily enforce this. - */ -export type UnsignedNormalizedDistance = number; diff --git a/frontend/src/lib/voter/vaa-matching/src/distance/index.ts b/frontend/src/lib/voter/vaa-matching/src/distance/index.ts index b374d4a5f..b4dba0561 100644 --- a/frontend/src/lib/voter/vaa-matching/src/distance/index.ts +++ b/frontend/src/lib/voter/vaa-matching/src/distance/index.ts @@ -1,9 +1,3 @@ -export {assertSignedNormalized, assertUnsignedNormalized} from './assert'; -export { - NORMALIZED_DISTANCE_EXTENT, - type SignedNormalizedDistance, - type UnsignedNormalizedDistance -} from './distance'; export {measureDistance} from './measure'; export type {DistanceMeasurementOptions, GlobalAndSubspaceDistances} from './measure.type'; -export {directionalDistance, manhattanDistance, DistanceMetric} from './metric'; +export {DISTANCE_METRIC, type DistanceMetric} from './metric'; diff --git a/frontend/src/lib/voter/vaa-matching/src/distance/measure.ts b/frontend/src/lib/voter/vaa-matching/src/distance/measure.ts index 0f576e546..d493c1960 100644 --- a/frontend/src/lib/voter/vaa-matching/src/distance/measure.ts +++ b/frontend/src/lib/voter/vaa-matching/src/distance/measure.ts @@ -1,90 +1,71 @@ -import {imputeMissingValues, MISSING_VALUE} from '../missingValue'; -import type {MatchingSpace} from '../space/matchingSpace'; -import type {MatchingSpacePosition} from '../space/position'; -import type {UnsignedNormalizedDistance} from './distance'; -import {DistanceMetric, directionalDistance, manhattanDistance} from './metric'; +import type {NormalizedDistance} from 'vaa-shared'; +import {imputeMissingPosition} from '../missingValue'; +import {MatchingSpace, type Position} from '../space'; import type {DistanceMeasurementOptions, GlobalAndSubspaceDistances} from './measure.type'; -export function measureDistance( - a: MatchingSpacePosition, - b: MatchingSpacePosition, - options: DistanceMeasurementOptions -): UnsignedNormalizedDistance; +export function measureDistance(params: { + reference: Position; + target: Position; + options: DistanceMeasurementOptions; +}): NormalizedDistance; -export function measureDistance( - a: MatchingSpacePosition, - b: MatchingSpacePosition, - options: DistanceMeasurementOptions, - subspaces: MatchingSpace[] -): GlobalAndSubspaceDistances; +export function measureDistance(params: { + reference: Position; + target: Position; + options: DistanceMeasurementOptions; + subspaces: ReadonlyArray; +}): GlobalAndSubspaceDistances; /** - * Measure the distance between to positions in a `MatchingSpace`. - * - * @param a The reference position to measure against (cannot be missing) - * @param b The other position - * @param options See the interface `DistanceMeasurementOptions` - * @param subspaces A list of subspaces in which distances are also measured. - * Used to compute, e.g., matches within question categories, in which case - * pass a llist of `MatchingSpaces`, where the weights of irrelevant questions - * are zero. It's a bit clunky to deal with subspaces here and not on a higher - * level, but this way we can avoid duplicate missing value imputations and - * distance calculations. - * @returns An unsigned normalized distance, e.g. [0, 1] (the range is defined - * by `NORMALIZED_DISTANCE_EXTENT`) or a list of such distances if `subspaces` - * is provided. + * Measure the distance between to positions in a `MatchingSpace` and possible subspaces. The process follows these steps: + * 1. Impute values for missing values if necessary. + * 2. Measure the distances using the provided distance metric in the global space and all subspaces. + * NB. The reference position `reference` is treated differently from `target` when dealing with missing values. Thus, switching them will in general yield a different result. + * @param reference The reference position to measure against. + * @param target The other position + * @param options See the `DistanceMeasurementOptions` type + * @param subspaces A list of subspaces in which distances are also measured. Used to compute, e.g., matches within question categories, in which case pass a llist of `MatchingSpaces`, where the weights of irrelevant questions are zero. It's a bit clunky to deal with subspaces here and not on a higher level, but this way we can avoid duplicate missing value imputations and distance calculations. + * @returns A normalized distance, e.g. [0, 1] (the range is defined by `[0, COORDINATE.Extent]`) or both a global distance and a list of distances in `subspaces` if they are provided. */ -export function measureDistance( - a: MatchingSpacePosition, - b: MatchingSpacePosition, - options: DistanceMeasurementOptions, - subspaces?: MatchingSpace[] -): UnsignedNormalizedDistance | GlobalAndSubspaceDistances { - if (a.dimensions === 0) throw new Error("a doesn't have any elements!"); - if (a.dimensions !== b.dimensions) throw new Error('a and b have different number of elements!'); - const space = a.space; - if (space && space.dimensions !== a.dimensions) - throw new Error('a and space have different number of dimensions!'); +export function measureDistance({ + reference, + target, + options, + subspaces +}: { + reference: Position; + target: Position; + options: DistanceMeasurementOptions; + subspaces?: ReadonlyArray; +}): NormalizedDistance | GlobalAndSubspaceDistances { + // Check that all relevant spaces are compatible + const space = reference.space; + if (space.shape.length === 0) throw new Error('The matching space doesn’t have any dimensions.'); + if (space !== target.space) throw new Error('a and b are in different spaces.'); if (subspaces) { for (const subspace of subspaces) { - if (subspace.dimensions !== a.dimensions) - throw new Error('a and at least one subspace have different number of dimensions!'); + if (!space.isCompatible(subspace)) + throw new Error('a and at least one subspace have different number of dimensions.'); } } - const sums = { - global: 0, - subspaces: subspaces == null ? [] : subspaces.map(() => 0) + // Impute missing values for `target` + const imputed = imputeMissingPosition({reference, target, options: options.missingValueOptions}); + // Calculate global distance + const global = options.metric({ + a: reference, + b: imputed, + allowMissing: options.allowMissingReference + }); + if (!subspaces) return global; + return { + global, + subspaces: subspaces.map((s) => + options.metric({ + a: reference, + b: imputed, + space: s, + allowMissing: options.allowMissingReference + }) + ) }; - for (let i = 0; i < a.dimensions; i++) { - // We might have to alter these values, if there are missing ones, hence the vars - let valA = a.coordinates[i], - valB = b.coordinates[i]; - // First, handle missing values (we use == to allow undefined | null just in case) - if (valA == MISSING_VALUE) throw new Error('The first position cannot contain missing values!'); - if (valB == MISSING_VALUE) - [valA, valB] = imputeMissingValues(valA, options.missingValueOptions); - // Calculate distance - let dist: number; - switch (options.metric) { - case DistanceMetric.Manhattan: - dist = manhattanDistance(valA, valB); - break; - case DistanceMetric.Directional: - dist = directionalDistance(valA, valB); - break; - default: - throw new Error(`Unknown distance metric: ${options.metric}`); - } - // Apply to totals - sums.global += dist * (space ? space.weights[i] : 1); - if (subspaces) { - subspaces.forEach((subspace, k) => (sums.subspaces[k] += dist * subspace.weights[i])); - } - } - // Normalize total distances - sums.global /= space ? space.maxDistance : a.dimensions; - if (subspaces) { - subspaces.forEach((subspace, k) => (sums.subspaces[k] /= subspace.maxDistance)); - } - return subspaces == null ? sums.global : sums; } diff --git a/frontend/src/lib/voter/vaa-matching/src/distance/measure.type.ts b/frontend/src/lib/voter/vaa-matching/src/distance/measure.type.ts index 5cf386ecb..fe684ba3c 100644 --- a/frontend/src/lib/voter/vaa-matching/src/distance/measure.type.ts +++ b/frontend/src/lib/voter/vaa-matching/src/distance/measure.type.ts @@ -1,22 +1,23 @@ +import type {NormalizedDistance} from 'vaa-shared'; import type {MissingValueImputationOptions} from '../missingValue'; -import type {UnsignedNormalizedDistance} from './distance'; import type {DistanceMetric} from './metric'; /** * Options passed to measureDistance. */ -export interface DistanceMeasurementOptions { +export type DistanceMeasurementOptions = { /** The distance metric to use. */ metric: DistanceMetric; - /** Options passed to imputeMissingValues */ + /** Options passed to imputeMissingValue */ missingValueOptions: MissingValueImputationOptions; -} + /** Whether to allow misssing reference values. If `true` and dimensions with missing coordinates in either `Position` are ignored. Otherwise, an error will be thrown in such cases. */ + allowMissingReference?: boolean; +}; /** - * A return type for the measureDistance function that includes both - * the global and subspace distances. The latter are used for SubMatches. + * A return type for the measureDistance function that includes both the global and subspace distances. The latter are used for SubMatches. */ export type GlobalAndSubspaceDistances = { - global: UnsignedNormalizedDistance; - subspaces: UnsignedNormalizedDistance[]; + global: NormalizedDistance; + subspaces: Array; }; diff --git a/frontend/src/lib/voter/vaa-matching/src/distance/metric.ts b/frontend/src/lib/voter/vaa-matching/src/distance/metric.ts index b5f725125..674fcaa7e 100644 --- a/frontend/src/lib/voter/vaa-matching/src/distance/metric.ts +++ b/frontend/src/lib/voter/vaa-matching/src/distance/metric.ts @@ -1,61 +1,252 @@ -import { - NORMALIZED_DISTANCE_EXTENT, - type SignedNormalizedDistance, - type UnsignedNormalizedDistance -} from './distance'; +import {COORDINATE, isMissingValue, type Coordinate, type NormalizedDistance} from 'vaa-shared'; +import {flatten, type MatchingSpace, type Position} from '../space'; /** - * Available distance measurement metrics + * References: + * - Fernando Mendez (2017) Modeling proximity and directional decisional logic: What can we learn from applying statistical learning techniques to VAA-generated data?, Journal of Elections, Public Opinion and Parties, 27:1, 31-55, DOI: [10.1080/17457289.2016.1269113](https://doi.org/10.1080/17457289.2016.1269113). */ -export enum DistanceMetric { - /** Sum of the distances in each dimension */ - Manhattan, + +/** + * Available distance measurement metrics. The values contain both a function for measuring the distance using the metric as well as one for finding the maximum available distance for the metric. + */ +export const DISTANCE_METRIC: Record = { /** - * Sum of the products of the distances in each dimension. Note that - * this method assumes a neutral stance semantically meaning being - * unsure about the statement. Thus, if either of the positions being - * compared has a neutral stance on an issue, agreement for that will - * be 50%. - * - * Furthermore, the maximum available agreement will be less than - * 100% in all cases where the reference entity has any other answers - * than those at the extremes (i.e. 1 or 5 on a 5-pt Likert scale). - * - * More confusingly, this means that if both the voter's and the - * candidate's answer to a statement is, e.g., 2/5, their agreement - * will be 25%, not 100% even though their answer are identical. + * Sum of the distances in each dimension. The most commonly used metric in VAAs. */ - Directional - // MendezHybrid, // This should be easy to implement, just take a 50/50 - // average of Manhattan and Directional - // Euclidean, + Manhattan: manhattanDistance, + /** + * Sum of the products of the distances in each dimension. Note that this method assumes that a neutral stance means semantically being unsure about the statement. Thus, if either of the positions being compared has a neutral stance on an issue, agreement for that will be 50%. + * Furthermore, the maximum available agreement will be less than 100% in all cases where the reference entity has any other answers than those at the extremes (i.e. 1 or 5 on a 5-pt Likert scale). + * More confusingly, this means that if both the voter's and the candidate's answer to a statement is, e.g., 2/5, their agreement will be 25%, not 100% even though their answer are identical. + * See Mendez (2017, p. 51). + */ + Directional: directionalDistance, + /** + * An Euclidean distance is the square root of the sum of the squares of the distances in each dimension. + */ + Euclidean: euclideanDistance + // MendezHybrid, // This should be easy to implement, just take a 50/50 average of Manhattan and Directional // Mahalonobis +}; + +/** + * Any available distance metric function. + */ +export type DistanceMetric = (typeof DISTANCE_METRIC)[keyof typeof DISTANCE_METRIC]; + +/** + * The general shape for `DistanceMetric` functions. + */ +type MetricFunction = (params: { + a: Position; + b: Position; + space?: MatchingSpace; + allowMissing?: boolean; +}) => NormalizedDistance; + +/** + * Calculate the Manhattan distance between two `Position`s. See `distance` for more details. + * @param a The first `Position` + * @param b The second `Position` + * @param space An optional separate `MatchingSpace` in which to measure the distance. @default a.space + * @param allowMissing If `true` and dimensions with missing coordinates in either `Position` are ignored. Otherwise, an error will be thrown in such cases. @default false + * @returns A normalized distance + */ +export function manhattanDistance({ + a, + b, + space, + allowMissing +}: { + a: Position; + b: Position; + space?: MatchingSpace; + allowMissing?: boolean; +}): NormalizedDistance { + return distance({ + a, + b, + space, + allowMissing, + kernel: absoluteKernel, + sum: basicSum, + subdimWeight: basicDivision + }); +} + +/** + * Calculate the Directional distance between two `Position`s. See `distance` for more details. + * @param a The first `Position` + * @param b The second `Position` + * @param space An optional separate `MatchingSpace` in which to measure the distance. @default a.space + * @param allowMissing If `true` and dimensions with missing coordinates in either `Position` are ignored. Otherwise, an error will be thrown in such cases. @default false + * @returns A normalized distance + */ +export function directionalDistance({ + a, + b, + space, + allowMissing +}: { + a: Position; + b: Position; + space?: MatchingSpace; + allowMissing?: boolean; +}): NormalizedDistance { + return distance({ + a, + b, + space, + allowMissing, + kernel: directionalKernel, + sum: basicSum, + subdimWeight: basicDivision + }); +} + +/** + * Calculate the Euclidean distance between two `Position`s. See `distance` for more details. + * @param a The first `Position` + * @param b The second `Position` + * @param space An optional separate `MatchingSpace` in which to measure the distance. @default a.space + * @param allowMissing If `true` and dimensions with missing coordinates in either `Position` are ignored. Otherwise, an error will be thrown in such cases. @default false + * @returns A normalized distance + */ +export function euclideanDistance({ + a, + b, + space, + allowMissing +}: { + a: Position; + b: Position; + space?: MatchingSpace; + allowMissing?: boolean; +}): NormalizedDistance { + return distance({ + a, + b, + space, + allowMissing, + kernel: absoluteKernel, + sum: euclideanSum, + subdimWeight: euclideanSubdimWeight + }); } /** - * Calculate the Manhattan distance between two distances. - * - * @param a Signed distance - * @param b Signed distance - * @returns Unsigned distance + * A kernel that calculates the absolute the difference between two coordinates. */ -export function manhattanDistance( - a: SignedNormalizedDistance, - b: SignedNormalizedDistance -): UnsignedNormalizedDistance { +export function absoluteKernel(a: Coordinate, b: Coordinate): number { return Math.abs(a - b); } /** - * Calculate the directional distance between two positions. - * - * @param a Signed distance - * @param b Signed distance - * @returns Unsigned distance + * A kernel that calculates the directional difference between two coordinates. Adapted from the definition in (Mendez, 2021, p. 51). + */ +export function directionalKernel(a: Coordinate, b: Coordinate): number { + // If the coordinates were bound at [min, max] the formula below would do: + return ( + 0.5 * COORDINATE.Extent - + (2 * (a - COORDINATE.Neutral) * (b - COORDINATE.Neutral)) / COORDINATE.Extent + ); +} + +/** + * A basic sum of `values`. + */ +export function basicSum(values: Array): number { + return values.reduce((acc, val) => acc + val, 0); +} + +/** + * Calculates the Euclidean distance from an array of distances in dimensions + */ +export function euclideanSum(values: Array): number { + return Math.sqrt(values.reduce((acc, val) => acc + val ** 2, 0)); +} + +/** + * A basic division 1 by `values`. + */ +export function basicDivision(numDimensions: number): number { + return 1 / numDimensions; +} + +/** + * Calculates the weight for each subdimension for the Euclidean metric. We need to take the square root of the number of dimensions so that the (maximum) distances in spaces with shapes of, e.g., [1, 1] and [1, 3] are equal. + */ +export function euclideanSubdimWeight(numDimensions: number): number { + return 1 / Math.sqrt(numDimensions); +} + +/** + * Used internally to construct different distance metrics. + * NB. The spaces of both positions and that defined by the possible `space` parameter must be compatible. + * NB. In the rare case that the all of the dimensions speficied for both `Position`s have zero length, a distance of 50% will be returned. + * The process follows these steps: + * 1. Flatten the possible subdimensions of both positions + * 2. Create the effective weights for the flat dimensions from the weights of the `MatchingSpace` divided by `subdimWeight(numDimensions)` for possible subdimensions + * 3. Compute the distances between `Position`s for each (flattened) dimension using `kernel(a, b)` + * 4. Sum the distances for each dimension using `sum(distances)` + * 5. Divided the sum by a similar sum computed over the maximum possible distances + * @param a The first `Position` + * @param b The second `Position` + * @param kernel A kernel function to compute the distance between a pair of coordinates in one dimension. + * @param sum A function to compute the sum of the dimensions’ distances. + * @param subdimWeight A function to compute the relative weight of each subdimension. The function is passed the number of subdimensions and it should return the relative weight of one subdimension (the same is used for all). In most circumstances, this should be the inverse of `sum`. + * @param space An optional separate `MatchingSpace` in which to measure the distance. @default a.space + * @param allowMissing If `true` and dimensions with missing coordinates in either `Position` are ignored. Otherwise, an error will be thrown in such cases. @default false + * @returns A normalized distance */ -export function directionalDistance( - a: SignedNormalizedDistance, - b: SignedNormalizedDistance -): UnsignedNormalizedDistance { - return 0.5 * NORMALIZED_DISTANCE_EXTENT - (2 * a * b) / NORMALIZED_DISTANCE_EXTENT; +export function distance({ + a, + b, + kernel, + sum, + subdimWeight, + space, + allowMissing +}: { + a: Position; + b: Position; + kernel: (a: Coordinate, b: Coordinate) => number; + sum: (values: Array) => number; + subdimWeight: (numDimensions: number) => number; + space?: MatchingSpace; + allowMissing?: boolean; +}): NormalizedDistance { + space ??= a.space; + if (!space.isCompatible(b.space)) + throw new Error('The shapes of the parameters are incompatible.'); + // TODO: In theory, we could precompute `aFlat` and `weights` for the entire run of the matching algorithm, but it seems it would not provide much of a speed benefit because the operations involved are simple + // Flatten the positions for easier mapping + const aFlat = flatten(a.coordinates); + const bFlat = flatten(b.coordinates); + // Compute weights + const weights: Array = space.shape + .map((d, i) => { + let weight = space.weights[i]; + if (d === 1) return weight; + weight *= subdimWeight(d); + return Array.from({length: d}, () => weight); + }) + .flat(); + if (weights.length !== aFlat.length || weights.length !== bFlat.length) + throw new Error('The shapes of the parameters are incompatible after weigthing.'); + // Compute the distances and maximum weights for each dimension + // Ignore dimensions with missing coordinates if `allowMissing` is false + const distances = new Array(); + const maxima = new Array(); + for (let i = 0; i < weights.length; i++) { + if (isMissingValue(aFlat[i]) || isMissingValue(bFlat[i])) { + if (!allowMissing) throw new Error('Missing coordinates in Positions are not allowed.'); + continue; + } + distances.push(weights[i] * kernel(aFlat[i]!, bFlat[i]!)); + maxima.push(weights[i]); + } + const distance = sum(distances); + const maximum = sum(maxima); + return maximum === 0 ? COORDINATE.Extent / 2 : (COORDINATE.Extent * distance) / maximum; } diff --git a/frontend/src/lib/voter/vaa-matching/src/entity/hasMatchableAnswers.ts b/frontend/src/lib/voter/vaa-matching/src/entity/hasMatchableAnswers.ts deleted file mode 100644 index c2d70a302..000000000 --- a/frontend/src/lib/voter/vaa-matching/src/entity/hasMatchableAnswers.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type {MatchableQuestion} from '../question'; - -/** - * Entities to be matched must implement this interface. - */ -export interface HasMatchableAnswers { - answers: AnswerDict; -} - -/** - * A record of question id and answer value pairs. - */ -export type AnswerDict = { - [questionId: MatchableQuestion['id']]: AnswerValue; -}; - -/** - * The answer value is contained in a value property of the answer object so that arbitrary data may accompany it. - */ -export interface AnswerValue { - value: unknown; -} diff --git a/frontend/src/lib/voter/vaa-matching/src/entity/index.ts b/frontend/src/lib/voter/vaa-matching/src/entity/index.ts deleted file mode 100644 index e7383e192..000000000 --- a/frontend/src/lib/voter/vaa-matching/src/entity/index.ts +++ /dev/null @@ -1 +0,0 @@ -export type {HasMatchableAnswers} from './hasMatchableAnswers'; diff --git a/frontend/src/lib/voter/vaa-matching/src/match/match.ts b/frontend/src/lib/voter/vaa-matching/src/match/match.ts index 9075badf4..c4e1aa7af 100644 --- a/frontend/src/lib/voter/vaa-matching/src/match/match.ts +++ b/frontend/src/lib/voter/vaa-matching/src/match/match.ts @@ -1,5 +1,4 @@ -import type {HasMatchableAnswers} from '../entity'; -import type {UnsignedNormalizedDistance} from '../distance'; +import type {HasAnswers, NormalizedDistance} from 'vaa-shared'; import type {MatchableQuestionGroup} from '../question'; import {MatchBase} from './matchBase'; import type {SubMatch} from './subMatch'; @@ -8,23 +7,29 @@ import type {SubMatch} from './subMatch'; * The class for an entity's matching result */ export class Match< - E extends HasMatchableAnswers = HasMatchableAnswers, - G extends MatchableQuestionGroup = MatchableQuestionGroup + TEntity extends HasAnswers = HasAnswers, + TGroup extends MatchableQuestionGroup = MatchableQuestionGroup > extends MatchBase { + readonly entity: TEntity; + readonly subMatches?: Array>; + /** - * Create a new Match. - * - * @param distance The match distance as an unsigned normalized distance, - * e.g. [0, 1] (the range is defined by `NORMALIZED_DISTANCE_EXTENT`). - * Note that 1 means a bad match and 0 a perfect one. + * Create a new `Match`. + * @param distance The match distance as an unsigned normalized distance, e.g. [0, 1] (the range is defined by `COORDINATE.Extent`). Note that a large distance (e.g. 1) means a bad match and a low one (e.g. 0) a perfect one. * @param entity The entity to which the match belongs. * @param subMatches Possible submatches for the entity. */ - constructor( - distance: UnsignedNormalizedDistance, - readonly entity: E, - readonly subMatches?: SubMatch[] - ) { + constructor({ + distance, + entity, + subMatches + }: { + distance: NormalizedDistance; + readonly entity: TEntity; + readonly subMatches?: Array>; + }) { super(distance); + this.entity = entity; + this.subMatches = subMatches; } } diff --git a/frontend/src/lib/voter/vaa-matching/src/match/matchBase.ts b/frontend/src/lib/voter/vaa-matching/src/match/matchBase.ts index f38926e55..eb584b245 100644 --- a/frontend/src/lib/voter/vaa-matching/src/match/matchBase.ts +++ b/frontend/src/lib/voter/vaa-matching/src/match/matchBase.ts @@ -1,79 +1,61 @@ -import { - assertUnsignedNormalized, - NORMALIZED_DISTANCE_EXTENT, - type UnsignedNormalizedDistance -} from '../distance'; +import {assertDistance, COORDINATE, type NormalizedDistance} from 'vaa-shared'; /** - * The base class for a matching result. In most cases, the subclass - * Match will be used. + * The base class for a matching result. In most cases, the subclass `Match` will be used. */ export class MatchBase { /** - * Used in converting the distance to a score value, typically - * between 0 and 100. This is a static value of the class, so - * change with `MatchBase.multiplier = numberVal`. - * */ + * Used in converting the distance to a score value, typically between 0 and 100. This is a static value of the class, so change with `MatchBase.multiplier = numberVal`. + */ static multiplier = 100; /** - * Used in converting the score to a string representation with - * toString(). This is a static value of the class, so change - * with `MatchBase.unitString = stringVal`. + * Used in converting the score to a string representation with toString(). This is a static value of the class, so change with `MatchBase.unitString = stringVal`. */ static unitString = '%'; // " %"; /** Used for to get/set `distance`. */ - private _distance: UnsignedNormalizedDistance = 0; + private _distance: NormalizedDistance = 0; /** * Create a new MatchBase. - * - * @param distance The match distance as an unsigned normalized distance, - * e.g. [0, 1] (the range is defined by `NORMALIZED_DISTANCE_EXTENT`). - * Note that 1 means a bad match and 0 a perfect one. + * @param distance The match distance as an unsigned normalized distance, e.g. [0, 1] (the range is defined by `COORDINATE.Extent`). Note that 1 means a bad match and 0 a perfect one. */ - constructor(distance: UnsignedNormalizedDistance) { + constructor(distance: NormalizedDistance) { this.distance = distance; } /** * Get the match distance as an unsigned normalized distance. */ - get distance(): UnsignedNormalizedDistance { + get distance(): NormalizedDistance { return this._distance; } /** * Set the match distance as an unsigned normalized distance. - * - * @param value The match distance as an unsigned normalized distance, - * e.g. [0, 1] (the range is defined by `NORMALIZED_DISTANCE_EXTENT`). - * Note that 1 means a bad match and 0 a perfect one. + * @param value The match distance as an unsigned normalized distance, e.g. [0, 1] (the range is defined by `COORDINATE.Extent`). Note that 1 means a bad match and 0 a perfect one. */ - set distance(value: UnsignedNormalizedDistance) { - assertUnsignedNormalized(value); + set distance(value: NormalizedDistance) { + assertDistance(value); this._distance = value; } /** - * Convert the distance to a fraction [0, 1], regardless of the value of - * `NORMALIZED_DISTANCE_EXTENT`. Note that 0 means a bad match and 1 a perfect one. + * Convert the distance to a fraction [0, 1], regardless of the value of `COORDINATE.Extent`. Note that 0 means a bad match and 1 a perfect one. */ get matchFraction(): number { - return (NORMALIZED_DISTANCE_EXTENT - this.distance) / NORMALIZED_DISTANCE_EXTENT; + return (COORDINATE.Extent - this.distance) / COORDINATE.Extent; } /** - * Convert the distance to a percentage [0, 100], regardless of the value of - * `NORMALIZED_DISTANCE_EXTENT`. Note that 0 means a bad match and 100 a perfect one. + * Convert the distance to a percentage [0, 100], regardless of the value of `COORDINATE.Extent`. Note that 0 means a bad match and 100 a perfect one. */ get score(): number { return Math.round(this.matchFraction * MatchBase.multiplier); } /** - * Convert to an understandable form, e.g. a percentage string. - * Override in subclasses. + * Convert to an understandable form, e.g. a percentage string. Override in subclasses. */ toString(): string { return `${this.score}${MatchBase.unitString}`; diff --git a/frontend/src/lib/voter/vaa-matching/src/match/subMatch.ts b/frontend/src/lib/voter/vaa-matching/src/match/subMatch.ts index 82188242f..900866235 100644 --- a/frontend/src/lib/voter/vaa-matching/src/match/subMatch.ts +++ b/frontend/src/lib/voter/vaa-matching/src/match/subMatch.ts @@ -1,24 +1,21 @@ -import type {UnsignedNormalizedDistance} from '../distance'; +import type {NormalizedDistance} from 'vaa-shared'; import type {MatchableQuestionGroup} from '../question'; import {MatchBase} from './matchBase'; /** * The class for question-group-specific submatches within a Match. */ -export class SubMatch extends MatchBase { +export class SubMatch< + TGroup extends MatchableQuestionGroup = MatchableQuestionGroup +> extends MatchBase { + readonly questionGroup: TGroup; /** - * Create a new SubMatch. - * - * @param distance The match distance as an unsigned normalized distance, - * e.g. [0, 1] (the range is defined by `NORMALIZED_DISTANCE_EXTENT`). - * Note that 1 means a bad match and 0 a perfect one. - * @param questionGroup The subgroup of questions for which the match is - * computed. + * Create a new `SubMatch`. + * @param distance The match distance as an unsigned normalized distance, e.g. [0, 1] (the range is defined by `COORDINATE.Extent`). Note that a large distance (e.g. 1) means a bad match and a low one (e.g. 0) a perfect one. + * @param questionGroup The subgroup of questions for which the match is computed. */ - constructor( - distance: UnsignedNormalizedDistance, - readonly questionGroup: T - ) { + constructor({distance, questionGroup}: {distance: NormalizedDistance; questionGroup: TGroup}) { super(distance); + this.questionGroup = questionGroup; } } diff --git a/frontend/src/lib/voter/vaa-matching/src/missingValue/bias.ts b/frontend/src/lib/voter/vaa-matching/src/missingValue/bias.ts index a1e1a7099..7c9d7fc67 100644 --- a/frontend/src/lib/voter/vaa-matching/src/missingValue/bias.ts +++ b/frontend/src/lib/voter/vaa-matching/src/missingValue/bias.ts @@ -1,12 +1,11 @@ /** - * The direction into which the missing value is biased when the reference - * value is neutral. I.e. if we use the RelativeMaximum method and the - * reference value is neutral (3 on a 5-pt Likert scale) and the bias is - * Positive, we impute the maximum value (5) for the missing value. + * The direction into which the missing value is biased when the reference value is neutral. I.e. if we use the RelativeMaximum method and the reference value is neutral (3 on a 5-pt Likert scale) and the bias is Positive, we impute the maximum value (5) for the missing value. */ -export enum MissingValueBias { +export const MISSING_VALUE_BIAS = { /** Biased toward the maximum value, e.g. 5 on a 5-pt scale. */ - Positive, + Positive: 'positive', /** Biased toward the minimum value, e.g. 1 on a 5-pt scale. */ - Negative -} + Negative: 'negative' +} as const; + +export type MissingValueBias = (typeof MISSING_VALUE_BIAS)[keyof typeof MISSING_VALUE_BIAS]; diff --git a/frontend/src/lib/voter/vaa-matching/src/missingValue/distanceMethod.ts b/frontend/src/lib/voter/vaa-matching/src/missingValue/distanceMethod.ts deleted file mode 100644 index 754f6ddf2..000000000 --- a/frontend/src/lib/voter/vaa-matching/src/missingValue/distanceMethod.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Method for calculating the penalty applied to missing values by imputing - * values for them - */ -export enum MissingValueDistanceMethod { - /** Impute a neutral value, i.e. 0 in NormalizedDistance terms */ - Neutral, - /** Imputes the furthest possible answer from the reference value, - * i.e., voter answer */ - RelativeMaximum, - /** Treats both the reference value and the missing one as - * being at the opposite ends of the range */ - AbsoluteMaximum -} diff --git a/frontend/src/lib/voter/vaa-matching/src/missingValue/impute.ts b/frontend/src/lib/voter/vaa-matching/src/missingValue/impute.ts index 590b99bbb..e041ceea3 100644 --- a/frontend/src/lib/voter/vaa-matching/src/missingValue/impute.ts +++ b/frontend/src/lib/voter/vaa-matching/src/missingValue/impute.ts @@ -1,68 +1,73 @@ -import { - NORMALIZED_DISTANCE_EXTENT, - assertSignedNormalized, - type SignedNormalizedDistance -} from '../distance'; -import {MissingValueBias} from './bias'; -import {MissingValueDistanceMethod} from './distanceMethod'; +import {assertCoordinate, COORDINATE, isMissingValue, type Coordinate} from 'vaa-shared'; +import {type MissingValueBias, MISSING_VALUE_BIAS} from './bias'; +import {MISSING_VALUE_METHOD, type MissingValueMethod} from './missingValueMethod'; +import {flatten, reshape, Position} from '../space'; /** - * Options passed to imputeMissingValues + * Options passed to imputeMissingValue */ export interface MissingValueImputationOptions { /** The method used for imputing missing values. */ - missingValueMethod: MissingValueDistanceMethod; - /** The direction of the bias used in imputing missing values, - * when the reference value is neutral. */ - missingValueBias?: MissingValueBias; + method: MissingValueMethod; + /** The direction of the bias used in imputing missing values, when the reference value is neutral. */ + bias?: MissingValueBias; } /** - * Impute a value for a missing one. Note that also the referenceValue may - * be affected, so both are returned. - * - * @param referenceValue The value used as reference, e.g. the voter's answer - * @param method The imputation method - * @param bias The direction of the bias used in imputing missing values, - * when the reference value is neutral - * @returns Tuple of [imputed value for the reference, imputed value for - * the missing one]. Note that the imputed reference value can only change - * when using the `AbsoluteMaximum` method. + * Impute a value for a missing one. + * @param reference The value used as reference, e.g. the voter's answer + * @param method The imputation method + * @param bias The direction of the bias used in imputing missing values, when the reference value is neutral + * @returns The imputed coordinate. */ -export function imputeMissingValues( - referenceValue: SignedNormalizedDistance, - options: MissingValueImputationOptions -): [SignedNormalizedDistance, SignedNormalizedDistance] { +export function imputeMissingValue({ + reference, + options +}: { + reference: Coordinate; + options: MissingValueImputationOptions; +}): Coordinate { // To be sure - assertSignedNormalized(referenceValue); - // For convenience - const maxAbsDistance = NORMALIZED_DISTANCE_EXTENT / 2; - const bias = options.missingValueBias ?? MissingValueBias.Positive; - // This will hold the imputed value for the missing one - let missingValue: SignedNormalizedDistance; - switch (options.missingValueMethod) { - case MissingValueDistanceMethod.Neutral: - // Treat value b as neutral - missingValue = 0; - break; - case MissingValueDistanceMethod.RelativeMaximum: - // Treat value b as the opposite extreme of value a, i.e. on a 5-point scale - // b is 5 if a is 1 or 2; 1 if a is 4 or 5; for 3, bias defines the direction - if (referenceValue == 0) - missingValue = bias === MissingValueBias.Positive ? maxAbsDistance : -maxAbsDistance; - else missingValue = referenceValue < 0 ? maxAbsDistance : -maxAbsDistance; - break; - case MissingValueDistanceMethod.AbsoluteMaximum: - // Treat the values as being at the opposite ends of the range - // Note that the bias behaviour for valA is reversed, its direction is thought - // to affect valB - if (referenceValue == 0) - referenceValue = bias === MissingValueBias.Positive ? -maxAbsDistance : maxAbsDistance; - else referenceValue = referenceValue < 0 ? -maxAbsDistance : maxAbsDistance; - missingValue = -referenceValue; - break; + assertCoordinate(reference); + const bias = options.bias ?? MISSING_VALUE_BIAS.Positive; + switch (options.method) { + case MISSING_VALUE_METHOD.Neutral: + return COORDINATE.Neutral; + case MISSING_VALUE_METHOD.RelativeMaximum: + // Treat value b as the opposite extreme of value a, i.e. on a 5-point scale b is 5 if a is 1 or 2; 1 if a is 4 or 5; for 3, bias defines the direction + if (reference == COORDINATE.Neutral) + return bias === MISSING_VALUE_BIAS.Positive ? COORDINATE.Max : COORDINATE.Min; + return reference < COORDINATE.Neutral ? COORDINATE.Max : COORDINATE.Min; default: - throw new Error(`Unknown missing value method: ${options.missingValueMethod}`); + throw new Error(`Unknown missing value method: ${options.method}`); } - return [referenceValue, missingValue]; +} + +/** + * Impute a `Position` for where coordinates missing in `target` are imputed based on `reference`. + * NB. If a coordinate is missing in `target`, the neutral coordinate is imputed. + * @param reference The `Position` used as reference, e.g. the voter's answers + * @param target The `Position` for which to impute missing coordinates + * @param options Options passed to `imputeMissingValue` + * @returns The imputed position. + */ +export function imputeMissingPosition({ + reference, + target, + options +}: { + reference: Position; + target: Position; + options: MissingValueImputationOptions; +}): Position { + // Flatten both positions for easier mapping + const flatRef = flatten(reference.coordinates); + const flatCoordinates = flatten(target.coordinates).map((c, i) => { + if (!isMissingValue(c)) return c; + const ref = flatRef[i]; + if (isMissingValue(ref)) return COORDINATE.Neutral; + return imputeMissingValue({reference: ref!, options}); + }); + const coordinates = reshape({flat: flatCoordinates, shape: target.shape}); + return new Position({coordinates, space: target.space}); } diff --git a/frontend/src/lib/voter/vaa-matching/src/missingValue/index.ts b/frontend/src/lib/voter/vaa-matching/src/missingValue/index.ts index b67a6581c..4197eaebc 100644 --- a/frontend/src/lib/voter/vaa-matching/src/missingValue/index.ts +++ b/frontend/src/lib/voter/vaa-matching/src/missingValue/index.ts @@ -1,4 +1,7 @@ -export {MissingValueBias} from './bias'; -export {MissingValueDistanceMethod} from './distanceMethod'; -export {imputeMissingValues, type MissingValueImputationOptions} from './impute'; -export {MISSING_VALUE} from './missingValue'; +export {type MissingValueBias, MISSING_VALUE_BIAS} from './bias'; +export {MISSING_VALUE_METHOD, type MissingValueMethod} from './missingValueMethod'; +export { + imputeMissingValue, + imputeMissingPosition, + type MissingValueImputationOptions +} from './impute'; diff --git a/frontend/src/lib/voter/vaa-matching/src/missingValue/missingValue.ts b/frontend/src/lib/voter/vaa-matching/src/missingValue/missingValue.ts deleted file mode 100644 index da7a5e609..000000000 --- a/frontend/src/lib/voter/vaa-matching/src/missingValue/missingValue.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** - * This can be returned when a missing value is needed - */ -export const MISSING_VALUE = undefined; diff --git a/frontend/src/lib/voter/vaa-matching/src/missingValue/missingValueMethod.ts b/frontend/src/lib/voter/vaa-matching/src/missingValue/missingValueMethod.ts new file mode 100644 index 000000000..5b84941a8 --- /dev/null +++ b/frontend/src/lib/voter/vaa-matching/src/missingValue/missingValueMethod.ts @@ -0,0 +1,11 @@ +/** + * Method for calculating the penalty applied to missing values by imputing values for them + */ +export const MISSING_VALUE_METHOD = { + /** Impute a neutral value, i.e. 0 in NormalizedDistance terms */ + Neutral: 'neutral', + /** Imputes the furthest possible answer from the reference value, i.e., voter answer */ + RelativeMaximum: 'relativeMaximum' +} as const; + +export type MissingValueMethod = (typeof MISSING_VALUE_METHOD)[keyof typeof MISSING_VALUE_METHOD]; diff --git a/frontend/src/lib/voter/vaa-matching/src/question/categoricalQuestion.ts b/frontend/src/lib/voter/vaa-matching/src/question/categoricalQuestion.ts new file mode 100644 index 000000000..700e9edd6 --- /dev/null +++ b/frontend/src/lib/voter/vaa-matching/src/question/categoricalQuestion.ts @@ -0,0 +1,64 @@ +import { + type MatchableQuestion, + type CoordinateOrMissing, + MISSING_VALUE, + type Id, + COORDINATE +} from 'vaa-shared'; + +interface MultipleChoiceValue { + id: Id; +} + +/** + * An example implementation for categorical multiple choice questions. Categorality means that the question’s values cannot be ordered. For example, if the question and answering choices are "Favourite color?" and red, blue or green, this means that the matching distance between "red" and "blue" is the same as between "red" and "green". + * NB. The mathematical model we use treats categorical questions as a combination of `n` binary choices or dimensions where `n` is the number of choices, unless `n` is 2, in which case a single dimension suffices. Because of this, categorical questions will **only yield distances of `[0, 2/n]` where `n` is the number of choices**, i.e., for the favourite color example, the match score can only be 100% in case of agreement or ~33% for disagreement. For binary questions, the range is 0–100%. + * This behavior is semantically motivated by the treatment of the questions as binary choices. In the example, this would map to in case of A answering "red" and B, "blue": + * - Red is favored?: A yes, B no => disagree + * - Blue is favored?: A no, B yes => disagree + * - Green is favored?: A no, B no => agree + * Thus, the respondents disagree on two counts but agree on one. In contrast, had they given the same answer, agreement would be perfect regardless of the number of choices. + * If such behavior limited in the level of disagreement is not what you want, this can be partially remedied by increasing the question weight to `n/2`. Note, however, that this will correct its weighting compared to other questions, but it will not yield full disagreement when looking at the question in isolation because the matching algorithm will normalise the distance using the maximum possible distance after computing it. + */ +export class CategoricalQuestion implements MatchableQuestion { + readonly id: Id; + readonly values: ReadonlyArray; + [key: string]: unknown; + + /** + * @param id Unique id + * @param values Array of objects with a value property + * @param ordinal Whether the question is ordinal (e.g. Likert scale) @default true + */ + constructor({id, values}: {id: Id; values: ReadonlyArray}) { + if (values.length < 2) throw new Error('There must be at least 2 values in the values array.'); + this.id = id; + this.values = values; + } + + /** + * Binary questions are treated as one-dimensional, but others have multiple dimensions. + */ + get normalizedDimensions(): number { + return this.values.length === 2 ? 1 : this.values.length; + } + + /** + * Used to convert answers to the question into normalized distances for used in matching. + * @param value A question's native value + * @returns The value in the signed normalized range (e.g. [-.5, .5]) + */ + normalizeValue(value: unknown): CoordinateOrMissing | Array { + if (value == null) + return this.values.length === 2 ? MISSING_VALUE : this.values.map(() => MISSING_VALUE); + if (!(typeof value === 'string')) + throw new Error(`Value must be a string! Got ${typeof value}`); + const choice = this.values.find((v) => v.id === value); + if (choice == null) throw new Error(`Choice with id ${value} not found in question.`); + // The mathematical model we use treats categorical questions as a combination of `n` binary choices or dimensions where `n` is the number of choices, unless `n` is 2, in which case a single dimension suffices. + if (this.values.length === 2) + return this.values.indexOf(choice) === 0 ? COORDINATE.Min : COORDINATE.Max; + // Otherwise, create subdmensions where we assign the max value to the selected choice and min to others + return this.values.map((v) => (v === choice ? COORDINATE.Max : COORDINATE.Min)); + } +} diff --git a/frontend/src/lib/voter/vaa-matching/src/question/index.ts b/frontend/src/lib/voter/vaa-matching/src/question/index.ts index f5728d77c..2d6b8d1fb 100644 --- a/frontend/src/lib/voter/vaa-matching/src/question/index.ts +++ b/frontend/src/lib/voter/vaa-matching/src/question/index.ts @@ -1,3 +1,3 @@ -export type {MatchableQuestion} from './matchableQuestion'; export type {MatchableQuestionGroup} from './matchableQuestionGroup'; -export {MultipleChoiceQuestion} from './multipleChoiceQuestion'; +export {CategoricalQuestion} from './categoricalQuestion'; +export {OrdinalQuestion} from './ordinalQuestion'; diff --git a/frontend/src/lib/voter/vaa-matching/src/question/matchableQuestion.ts b/frontend/src/lib/voter/vaa-matching/src/question/matchableQuestion.ts deleted file mode 100644 index 351f4b369..000000000 --- a/frontend/src/lib/voter/vaa-matching/src/question/matchableQuestion.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type {MatchingSpaceCoordinate} from '../space'; - -/** - * The interface for all question used for matching. - */ -export interface MatchableQuestion { - /** - * The entities' answers to questions are matched using the question id - */ - id: string; - /** - * Set this to more than 1 for questions, such as preference order, - * that produce multidimensional normalized values - */ - normalizedDimensions?: number; - /** - * Preference order questions return a list of distances, but Likert questions just one number - */ - normalizeValue(value: unknown): MatchingSpaceCoordinate | MatchingSpaceCoordinate[]; -} diff --git a/frontend/src/lib/voter/vaa-matching/src/question/matchableQuestionGroup.ts b/frontend/src/lib/voter/vaa-matching/src/question/matchableQuestionGroup.ts index 8545c1f41..a0283580d 100644 --- a/frontend/src/lib/voter/vaa-matching/src/question/matchableQuestionGroup.ts +++ b/frontend/src/lib/voter/vaa-matching/src/question/matchableQuestionGroup.ts @@ -1,10 +1,8 @@ -import type {MatchableQuestion} from './matchableQuestion'; +import type {MatchableQuestion} from 'vaa-shared'; /** - * Question groups are used for subcategory matches in matching algorithm. - * They must have questions, but may also have other properties, such - * as a label or link to a question category. + * Question groups are used for subcategory matches in matching algorithm. They must have questions, but may also have other properties, such as a label or link to a question category. */ export interface MatchableQuestionGroup { - matchableQuestions: MatchableQuestion[]; + matchableQuestions: Array; } diff --git a/frontend/src/lib/voter/vaa-matching/src/question/multipleChoiceQuestion.ts b/frontend/src/lib/voter/vaa-matching/src/question/multipleChoiceQuestion.ts deleted file mode 100644 index a09aa2c3f..000000000 --- a/frontend/src/lib/voter/vaa-matching/src/question/multipleChoiceQuestion.ts +++ /dev/null @@ -1,67 +0,0 @@ -import {NORMALIZED_DISTANCE_EXTENT} from '../distance'; -import {MISSING_VALUE} from '../missingValue'; -import type {MatchingSpaceCoordinate} from '../space'; -import type {MatchableQuestion} from './matchableQuestion'; - -interface MultipleChoiceValue { - value: number; -} - -/** - * An example implementation for multiple choice questions, including - * Likert questions - */ -export class MultipleChoiceQuestion implements MatchableQuestion { - readonly maxValue: number; - readonly minValue: number; - readonly normalizedDimensions = 1; - [key: string]: unknown; - - /** - * @param id Unique id - * @param values Array of objects with a value property - */ - constructor( - readonly id: string, - readonly values: MultipleChoiceValue[] - ) { - this.id = id; - this.values = values; - this.minValue = Math.min(...this.values.map((v) => v.value)); - this.maxValue = Math.max(...this.values.map((v) => v.value)); - } - - get neutralValue() { - return this.minValue + this.valueRange / 2; - } - - get valueRange(): number { - return this.maxValue - this.minValue; - } - - /** - * Used to convert a question's values into normalized distances for used - * in matching. - * @param value A question's native value - * @returns The value in the signed normalized range (e.g. [-.5, .5]) - */ - normalizeValue(value: number | undefined | null): MatchingSpaceCoordinate { - if (value == null) return MISSING_VALUE; - if (!(typeof value === 'number')) - throw new Error(`Value must be a number! Got ${typeof value}`); - if (value < this.minValue || value > this.maxValue) - throw new Error(`Value out of bounds [${this.minValue}, ${this.maxValue}]: ${value}`); - return NORMALIZED_DISTANCE_EXTENT * ((value - this.minValue) / this.valueRange - 0.5); - } - - /** - * Utility for creating Likert questions. - * @param scale The number of options to show - * @returns A MultipleChoiceQuestion object - */ - static fromLikert(id: string, scale: number) { - if (!Number.isSafeInteger(scale)) throw new Error('Scale must be an integer.'); - const values = Array.from({length: scale}, (_, i) => ({value: i + 1})); - return new MultipleChoiceQuestion(id, values); - } -} diff --git a/frontend/src/lib/voter/vaa-matching/src/question/ordinalQuestion.ts b/frontend/src/lib/voter/vaa-matching/src/question/ordinalQuestion.ts new file mode 100644 index 000000000..bcf381e67 --- /dev/null +++ b/frontend/src/lib/voter/vaa-matching/src/question/ordinalQuestion.ts @@ -0,0 +1,69 @@ +import { + type MatchableQuestion, + type CoordinateOrMissing, + MISSING_VALUE, + normalizeCoordinate, + type Id +} from 'vaa-shared'; + +interface MultipleChoiceValue { + id: Id; + value: number; +} + +/** + * An example implementation for an ordinal multiple choice questions, such as a Likert question. Ordinality means that the question’s values can be ordered and compared to each other. If this is not the case, use a `CategoricalQuestion` instead. + */ +export class OrdinalQuestion implements MatchableQuestion { + readonly id: Id; + readonly maxValue: number; + readonly minValue: number; + readonly normalizedDimensions = 1; + readonly values: ReadonlyArray; + [key: string]: unknown; + + /** + * @param id Unique id + * @param values Array of objects with a value property + */ + constructor({id, values}: {id: Id; values: ReadonlyArray}) { + if (values.length < 2) throw new Error('There must be at least 2 values in the values array.'); + this.id = id; + this.values = values; + this.minValue = Math.min(...this.values.map((v) => v.value)); + this.maxValue = Math.max(...this.values.map((v) => v.value)); + } + + get neutralValue() { + return this.minValue + this.valueRange / 2; + } + + get valueRange(): number { + return this.maxValue - this.minValue; + } + + /** + * Used to convert answers to the question into normalized distances for used in matching. + * @param value A question's native value + * @returns The value in the signed normalized range (e.g. [-.5, .5]) + */ + normalizeValue(value: unknown): CoordinateOrMissing { + if (value == null) return MISSING_VALUE; + if (!(typeof value === 'string')) + throw new Error(`Value must be a string! Got ${typeof value}`); + const choiceValue = this.values.find((v) => v.id === value)?.value; + if (choiceValue == null) throw new Error(`Choice with id ${value} not found in question.`); + return normalizeCoordinate({value: choiceValue, min: this.minValue, max: this.maxValue}); + } + + /** + * Utility for creating Likert questions. + * @param scale The number of options to show + * @returns A OrdinalQuestion object + */ + static fromLikert({id, scale}: {id: string; scale: number}): OrdinalQuestion { + if (!Number.isSafeInteger(scale)) throw new Error('Scale must be an integer.'); + const values = Array.from({length: scale}, (_, i) => ({id: `choice_${i + 1}`, value: i + 1})); + return new OrdinalQuestion({id, values}); + } +} diff --git a/frontend/src/lib/voter/vaa-matching/src/space/createSubspace.ts b/frontend/src/lib/voter/vaa-matching/src/space/createSubspace.ts index 0abcb3ad6..2cdbb7587 100644 --- a/frontend/src/lib/voter/vaa-matching/src/space/createSubspace.ts +++ b/frontend/src/lib/voter/vaa-matching/src/space/createSubspace.ts @@ -1,25 +1,24 @@ +import type {MatchableQuestion} from 'vaa-shared'; import {MatchingSpace} from './matchingSpace'; -import type {MatchableQuestion} from '../question'; /** - * A utility function to create a subspace for a subset of questions - * that can be passed to `measureDistance`. The main intended use is - * in computing submatches for question categories, such as, - * 'Economy' or 'The Environment'. - * - * @param allQuestions The full set of questions - * @param subset The subset of questions for which the subspace is - * created, effectively one where the weights of the dimensions - * pertaining to the exluded questions are zero. Note that if none - * of the questions overlap, all of the dimensions of the subspace - * will have zero length. + * A utility function to create a subspace for a subset of questions that can be passed to `measureDistance`. The main intended use is in computing submatches for question categories, such as, 'Economy' or 'The Environment'. + * @param questions The full set of questions + * @param subset The subset of questions for which the subspace is created, effectively one where the weights of the dimensions pertaining to the exluded questions are zero. Note that if none of the questions overlap, all of the dimensions of the subspace will have zero length. */ -export function createSubspace(allQuestions: MatchableQuestion[], subset: MatchableQuestion[]) { - const dimensionWeights: number[] = []; - for (const question of allQuestions) { - const dims = question.normalizedDimensions ?? 1; - const included = subset.indexOf(question) > -1; - dimensionWeights.push(...Array.from({length: dims}, () => (included ? 1 / dims : 0))); - } - return new MatchingSpace(dimensionWeights); +export function createSubspace({ + questions, + subset +}: { + questions: ReadonlyArray; + subset: ReadonlyArray; +}): MatchingSpace { + const subsetIds = new Set(subset.map((q) => q.id)); + const questionWeights = Object.fromEntries( + questions.filter((q) => !subsetIds.has(q.id)).map((q) => [q.id, 0]) + ); + return MatchingSpace.fromQuestions({ + questions, + questionWeights + }); } diff --git a/frontend/src/lib/voter/vaa-matching/src/space/index.ts b/frontend/src/lib/voter/vaa-matching/src/space/index.ts index f8836798a..95bd03d00 100644 --- a/frontend/src/lib/voter/vaa-matching/src/space/index.ts +++ b/frontend/src/lib/voter/vaa-matching/src/space/index.ts @@ -1,3 +1,4 @@ export {createSubspace} from './createSubspace'; export {MatchingSpace} from './matchingSpace'; -export {type MatchingSpaceCoordinate, MatchingSpacePosition} from './position'; +export {Position} from './position'; +export {coordinatesShape, equalShapes, flatten, reshape} from './shape'; diff --git a/frontend/src/lib/voter/vaa-matching/src/space/matchingSpace.ts b/frontend/src/lib/voter/vaa-matching/src/space/matchingSpace.ts index d232e301a..080c15338 100644 --- a/frontend/src/lib/voter/vaa-matching/src/space/matchingSpace.ts +++ b/frontend/src/lib/voter/vaa-matching/src/space/matchingSpace.ts @@ -1,29 +1,65 @@ -/** - * A space wherein the matching distances are measured. - */ +import type {Id, MatchableQuestion} from 'vaa-shared'; +import type {Position, PositionCoordinates} from './position'; +import {coordinatesShape, equalShapes, type Shape} from './shape'; + export class MatchingSpace { + /** + * A space wherein the matching distances are measured. + * The `MatchingSpace` consists of a number of dimensions, which can be weighted and which may have subdimensions. The subdimensions are used for questions which cannot be represented by a single dimension, such as, categorical questions with more than two options and preference order questions. + * The answers of an entity are converted into `Position`s in this space such that each answer is represented by a `Coordinate` in the dimension for that question, or `Coordinate`s in each of its subdimensions if these exist. For this conversion, the `normalizeValue` method of the relevant `MatchableQuestion` is relied on. + * Note that the `Coordinate` may also be missing in which case the `imputeMissingValue` function is used when matching. + */ + + /** + * The number of subdimensions for each dimensions in this space. + */ + readonly shape: Shape; + /** + * The weights for each first-order dimension in this space. + */ + weights: Array; + /** * Define a space for matching. - * - * @param weights The weights used for matching, i.e. distance calculation - * of each dimension. + * @param shape - Either the number of flat dimensions or an array containing the number of subdimensions for each dimension. + * @param weights - Optional weights to use for matching for each first-order dimension in this space. Default: 1 for each dimension. */ - constructor(public weights: number[]) { - if (weights.length === 0) throw new Error('Weights cannot be empty!'); + constructor({shape, weights}: {shape: number | Shape; weights?: Array}) { + if (typeof shape === 'number') shape = Array.from({length: shape}, () => 1); + weights ??= Array.from({length: shape.length}, () => 1); + if (shape.length !== weights.length) + throw new Error('Shape and weights must have the same length'); + this.shape = shape; + this.weights = weights; } /** - * Number of dimensions in this space. + * Check if this space is compatible with another `MatchingSpace`, `Position` or `PositionCoordinates`, i.e. that their dimensionality is the same but their weights may differ. */ - get dimensions(): number { - return this.weights.length; + isCompatible(target: MatchingSpace | Position | PositionCoordinates): boolean { + const targetShape: Shape = 'shape' in target ? target.shape : coordinatesShape(target); + return equalShapes(this.shape, targetShape); } /** - * Maximum possible distance in this space. Used to calculate matching - * distances in the space (they are fractions of the this). + * Create a `MatchingSpace` from a list of `MatchableQuestion`s with possible weighting. + * @param questions - The questions that defined the dimensions of the space. + * @param questionWeights - A full or partial record of question weights by id. If not specified at all or a given question, the weights default to 1. Note that weights for subdimensions cannot be supplied. */ - get maxDistance(): number { - return this.weights.reduce((s, i) => s + i, 0); + static fromQuestions({ + questions, + questionWeights + }: { + questions: ReadonlyArray; + questionWeights?: Record; + }): MatchingSpace { + const shape = new Array(questions.length); + const weights = new Array(questions.length); + for (let i = 0; i < questions.length; i++) { + const q = questions[i]; + shape[i] = q.normalizedDimensions ?? 1; + weights[i] = questionWeights?.[q.id] ?? 1; + } + return new MatchingSpace({shape, weights}); } } diff --git a/frontend/src/lib/voter/vaa-matching/src/space/position.ts b/frontend/src/lib/voter/vaa-matching/src/space/position.ts index 34ae3815c..ff0e47396 100644 --- a/frontend/src/lib/voter/vaa-matching/src/space/position.ts +++ b/frontend/src/lib/voter/vaa-matching/src/space/position.ts @@ -1,27 +1,30 @@ -import type {SignedNormalizedDistance} from '../distance'; -import type {MISSING_VALUE} from '../missingValue'; +import type {CoordinateOrMissing} from 'vaa-shared'; import type {MatchingSpace} from './matchingSpace'; +import {coordinatesShape, type Shape} from './shape'; /** - * A coordinate in a space defined by SignedNormalizedDistances that may be missing + * The coordinates may be have one level of subdimensions. */ -export type MatchingSpaceCoordinate = SignedNormalizedDistance | typeof MISSING_VALUE; +export type PositionCoordinates = Array>; /** * A position in a MatchingSpace */ -export class MatchingSpacePosition { - constructor( - public coordinates: MatchingSpaceCoordinate[], - public readonly space?: MatchingSpace - ) { - if (space && space.dimensions !== coordinates.length) - throw new Error( - `The dimensions of coordinates ${coordinates.length} and space ${space.dimensions} do not match!` - ); +export class Position { + coordinates: PositionCoordinates; + readonly space: MatchingSpace; + + constructor({coordinates, space}: {coordinates: PositionCoordinates; space: MatchingSpace}) { + if (!space.isCompatible(coordinates)) + throw new Error('The shape of coordinates and space are incompatible'); + this.coordinates = coordinates; + this.space = space; } - get dimensions(): number { - return this.coordinates.length; + /** + * The shape of the position. + */ + get shape(): Shape { + return coordinatesShape(this.coordinates); } } diff --git a/frontend/src/lib/voter/vaa-matching/src/space/shape.ts b/frontend/src/lib/voter/vaa-matching/src/space/shape.ts new file mode 100644 index 000000000..49544ed00 --- /dev/null +++ b/frontend/src/lib/voter/vaa-matching/src/space/shape.ts @@ -0,0 +1,51 @@ +import type {CoordinateOrMissing} from 'vaa-shared'; +import type {PositionCoordinates} from './position'; + +/** + * The shape of `MatchingSpace` or `Position` is the number of subdimensions for each dimension. + */ +export type Shape = Array; + +/** + * Return the shape of a `PositionCoordinates` object. */ +export function coordinatesShape(coordinates: PositionCoordinates): Shape { + return coordinates.map((c) => (Array.isArray(c) ? c.length : 1)); +} + +/** + * Check if the shapes are equal. + */ +export function equalShapes(a: Shape, b: Shape): boolean { + return a.length === b.length && a.every((d, i) => d === b[i]); +} + +/** + * Flatten a `PositionCoordinates` object if it contains subdimensions. + */ +export function flatten(coordinates: PositionCoordinates): Array { + if (coordinates.every((c) => typeof c === 'number')) return coordinates; + return coordinates.flat(); +} + +/** + * Reshape a flattened shape into its original `Shape`. + */ +export function reshape({ + flat, + shape +}: { + flat: Array; + shape: Shape; +}): PositionCoordinates { + if (shape.every((d) => d === 1)) return flat; + if (flat.length !== shape.reduce((acc, d) => acc + d, 0)) + throw new Error( + `Cannot reshape array of length ${flat.length} into shape [${shape.join(',')}]` + ); + let i = 0; + return shape.map((d) => { + const element = d === 1 ? flat[i] : flat.slice(i, i + d); + i += d; + return element; + }); +} diff --git a/frontend/src/lib/voter/vaa-matching/tests/algorithms.test.ts b/frontend/src/lib/voter/vaa-matching/tests/algorithms.test.ts index 77ad8916b..c8875dade 100644 --- a/frontend/src/lib/voter/vaa-matching/tests/algorithms.test.ts +++ b/frontend/src/lib/voter/vaa-matching/tests/algorithms.test.ts @@ -1,591 +1,282 @@ +import {MISSING_VALUE, COORDINATE} from 'vaa-shared'; +import {DISTANCE_METRIC} from '../src/distance'; +import {MISSING_VALUE_METHOD} from '../src/missingValue'; +import {CategoricalQuestion, OrdinalQuestion} from '../src/question'; +import {createMatchesAndEntities, createVoter, createCandidates} from './utils'; import {MatchingAlgorithm} from '../src/algorithms'; -import type {HasMatchableAnswers} from '../src/entity'; -import { - directionalDistance, - type DistanceMeasurementOptions, - DistanceMetric, - manhattanDistance, - measureDistance, - NORMALIZED_DISTANCE_EXTENT, - type SignedNormalizedDistance, - type UnsignedNormalizedDistance -} from '../src/distance'; -import type {Match} from '../src/match'; -import { - imputeMissingValues, - MISSING_VALUE, - MissingValueBias, - MissingValueDistanceMethod -} from '../src/missingValue'; -import {type MatchableQuestion, MultipleChoiceQuestion} from '../src/question'; -import {createSubspace, MatchingSpace, MatchingSpacePosition} from '../src/space'; -import type {AnswerDict} from '../src/entity/hasMatchableAnswers'; // For convenience -const maxDist = NORMALIZED_DISTANCE_EXTENT; -const maxVal: SignedNormalizedDistance = NORMALIZED_DISTANCE_EXTENT / 2; -const minVal: SignedNormalizedDistance = -maxVal; +const max = COORDINATE.Max; +const min = COORDINATE.Min; +const half = COORDINATE.Extent / 2; +const full = COORDINATE.Extent; -describe('single coordinate distance measurements', () => { - test('manhattanDistance', () => { - expect(manhattanDistance(maxVal, minVal), 'Commutatibility').toBeCloseTo( - manhattanDistance(minVal, maxVal) - ); - expect(manhattanDistance(maxVal, minVal)).toBeCloseTo(maxDist); - expect(manhattanDistance(maxVal, 0)).toBeCloseTo(maxVal); - expect(manhattanDistance(maxVal * 0.5, maxVal * 0.5)).toBeCloseTo(0); - }); - test('directionalDistance', () => { - expect(directionalDistance(maxVal, minVal), 'Commutatibility').toBeCloseTo( - directionalDistance(minVal, maxVal) - ); - expect(directionalDistance(maxVal, minVal)).toBeCloseTo(maxDist); - expect(directionalDistance(maxVal, 0)).toBeCloseTo(0.5 * maxDist); - expect(directionalDistance(maxVal * 0.25, 0)).toBeCloseTo(0.5 * maxDist); - expect(directionalDistance(minVal * 0.9, 0)).toBeCloseTo(0.5 * maxDist); - const halfAgreeVal = 0.5 * 0.5; // Calculated on a scale from -1 to 1 (disagree to agree) - const halfAgreeDist = ((1 - halfAgreeVal) / 2) * maxDist; // Convert to distance and scale with maxDist - expect(directionalDistance(maxVal * 0.5, maxVal * 0.5)).toBeCloseTo(halfAgreeDist); - expect(directionalDistance(maxVal * 0.5, minVal * 0.5)).toBeCloseTo(maxDist - halfAgreeDist); - }); -}); - -describe('imputeMissingValues', () => { - const refVals: SignedNormalizedDistance[] = [minVal, 0.3 * minVal, 0, 0.5 * maxVal, maxVal]; - describe('MissingValueDistanceMethod.Neutral', () => { - const opts = { - missingValueMethod: MissingValueDistanceMethod.Neutral, - missingValueBias: MissingValueBias.Positive - }; - test.each(refVals)('For %d', (refVal: SignedNormalizedDistance) => { - expect(imputeMissingValues(refVal, opts)[0], 'Ref value unchanged').toBe(refVal); - expect(imputeMissingValues(refVal, opts)[1], 'Imputed value always neutral').toBeCloseTo(0); - }); - }); - describe('MissingValueDistanceMethod.Neutral / MissingValueBias.Negative', () => { - const opts = { - missingValueMethod: MissingValueDistanceMethod.Neutral, - missingValueBias: MissingValueBias.Negative - }; - test.each(refVals)('For %d', (refVal: SignedNormalizedDistance) => { - expect(imputeMissingValues(refVal, opts)[0], 'Ref value unchanged').toBe(refVal); - expect(imputeMissingValues(refVal, opts)[1], 'Imputed value always neutral').toBeCloseTo(0); - }); - }); - describe('MissingValueDistanceMethod.RelativeMaximum', () => { - const opts = { - missingValueMethod: MissingValueDistanceMethod.RelativeMaximum, - missingValueBias: MissingValueBias.Positive - }; - test.each(refVals)('For %d', (refVal: SignedNormalizedDistance) => { - expect(imputeMissingValues(refVal, opts)[0], 'Ref value unchanged').toBe(refVal); - expect(imputeMissingValues(refVal, opts)[1], 'Imputed value reversed').toBeCloseTo( - refVal <= 0 ? maxVal : minVal - ); - }); - expect(imputeMissingValues(0, opts)[1], 'Pos bias for zero').toBeCloseTo(maxVal); - }); - // Imputed vals should be the ones above reversed - describe('MissingValueDistanceMethod.RelativeMaximum / MissingValueBias.Negative', () => { - const opts = { - missingValueMethod: MissingValueDistanceMethod.RelativeMaximum, - missingValueBias: MissingValueBias.Negative - }; - test.each(refVals)('For %d', (refVal: SignedNormalizedDistance) => { - expect(imputeMissingValues(refVal, opts)[0], 'Ref value unchanged').toBe(refVal); - expect(imputeMissingValues(refVal, opts)[1], 'Imputed value reversed').toBeCloseTo( - refVal >= 0 ? minVal : maxVal - ); - }); - expect(imputeMissingValues(0, opts)[1], 'Neg bias for zero').toBeCloseTo(minVal); - }); - describe('MissingValueDistanceMethod.AbsoluteMaximum', () => { - const opts = { - missingValueMethod: MissingValueDistanceMethod.AbsoluteMaximum, - missingValueBias: MissingValueBias.Positive - }; - test.each(refVals)('For %d', (refVal: SignedNormalizedDistance) => { - expect(imputeMissingValues(refVal, opts)[0], 'Ref value at max end').toBe( - refVal <= 0 ? minVal : maxVal - ); - expect(imputeMissingValues(refVal, opts)[1], 'Imputed value reversed').toBeCloseTo( - refVal <= 0 ? maxVal : minVal - ); - }); - expect(imputeMissingValues(0, opts)[0], 'Pos bias for zero').toBeCloseTo(minVal); - expect(imputeMissingValues(0, opts)[1], 'Pos bias for zero').toBeCloseTo(maxVal); - }); - // These should be the ones above reversed - describe('MissingValueDistanceMethod.AbsoluteMaximum / MissingValueBias.Negative', () => { - const opts = { - missingValueMethod: MissingValueDistanceMethod.AbsoluteMaximum, - missingValueBias: MissingValueBias.Negative - }; - test.each(refVals)('For %d', (refVal: SignedNormalizedDistance) => { - expect(imputeMissingValues(refVal, opts)[0], 'Ref value at max end').toBe( - refVal >= 0 ? maxVal : minVal - ); - expect(imputeMissingValues(refVal, opts)[1], 'Imputed value reversed').toBeCloseTo( - refVal >= 0 ? minVal : maxVal - ); - }); - expect(imputeMissingValues(0, opts)[0], 'Neg bias for zero').toBeCloseTo(maxVal); - expect(imputeMissingValues(0, opts)[1], 'Neg bias for zero').toBeCloseTo(minVal); - }); -}); - -describe('measureDistance', () => { - const weights = [1, 2, 3]; - const ms = new MatchingSpace(weights); - const mdOpts: DistanceMeasurementOptions[] = [ - { - metric: DistanceMetric.Directional, - missingValueOptions: { - missingValueMethod: MissingValueDistanceMethod.AbsoluteMaximum - } - }, - { - metric: DistanceMetric.Manhattan, - missingValueOptions: { - missingValueMethod: MissingValueDistanceMethod.AbsoluteMaximum - } - } - ]; - describe('Extreme distances', () => { - const posMin = new MatchingSpacePosition([minVal, minVal, minVal], ms); - const posMax = new MatchingSpacePosition([maxVal, maxVal, maxVal], ms); - const posMissing = new MatchingSpacePosition([MISSING_VALUE, MISSING_VALUE, MISSING_VALUE], ms); - test.each(mdOpts)('Max distance for $metric', (opts: DistanceMeasurementOptions) => { - expect(measureDistance(posMin, posMax, opts)).toBeCloseTo(maxDist); +describe('matchingAlgorithm', () => { + test('projectToNormalizedSpace', () => { + const likertScale = 5; + const values = [1, 3, 5]; + const coords = values.map((v) => { + const normalized = (v - 1) / (likertScale - 1); + return min + normalized * (max - min); }); - test.each(mdOpts)( - 'Max distance with missing vals for $metric', - (opts: DistanceMeasurementOptions) => { - expect(measureDistance(posMin, posMissing, opts)).toBeCloseTo(maxDist); - } - ); - test.each(mdOpts)( - 'Min distance with min vals for $metric', - (opts: DistanceMeasurementOptions) => { - expect(measureDistance(posMin, posMin, opts)).toBeCloseTo(0); - } - ); - test.each(mdOpts)( - 'Min distance with max vals for $metric', - (opts: DistanceMeasurementOptions) => { - expect(measureDistance(posMax, posMax, opts)).toBeCloseTo(0); - } + const {questions, candidates, algorithm} = createMatchesAndEntities( + values, + [values, values], + likertScale, + DISTANCE_METRIC.Manhattan, + MISSING_VALUE_METHOD.Neutral ); + expect( + algorithm.projectToNormalizedSpace({questions, targets: candidates})[0].coordinates + ).toEqual(coords); + expect( + algorithm.projectToNormalizedSpace({questions: questions.slice(1), targets: candidates})[0] + .coordinates, + 'Subset of answers based on question list' + ).toMatchObject(coords.slice(1)); }); - test('DistanceMetric.Manhattan', () => { - const opts: DistanceMeasurementOptions = { - metric: DistanceMetric.Manhattan, - missingValueOptions: { - missingValueMethod: MissingValueDistanceMethod.AbsoluteMaximum - } - }; - const posA = new MatchingSpacePosition([minVal, minVal, minVal], ms); - const posB = new MatchingSpacePosition([minVal, 0, maxVal], ms); - const dist = - (weights[0] * 0 + (weights[1] * maxDist) / 2 + weights[2] * maxDist) / ms.maxDistance; - expect(measureDistance(posA, posB, opts)).toBeCloseTo(dist); - expect(measureDistance(posB, posA, opts), 'Commutatibility').toBeCloseTo(dist); - }); - test('DistanceMetric.Directional', () => { - const opts: DistanceMeasurementOptions = { - metric: DistanceMetric.Directional, - missingValueOptions: { - missingValueMethod: MissingValueDistanceMethod.AbsoluteMaximum - } - }; - const f = 0.5; - const posA = new MatchingSpacePosition([f * minVal, minVal, minVal]); - const posB = new MatchingSpacePosition([f * minVal, 0, maxVal]); - const posC = new MatchingSpacePosition([f * maxVal, 0, maxVal]); - // Calculate first with factors on a scale from -1 to 1 (disagree to agree) - // where minVal = -1 and maxVal = 1 - let factorsAB = [f * -1 * (f * -1), -1 * 0, -1 * 1]; - // Convert to distance and scale with maxDist - factorsAB = factorsAB.map((v) => ((1 - v) / 2) * maxDist); - // Add up and divide by dims - const distAB = factorsAB.reduce((a, b) => a + b, 0) / 3; - // Ditto for A-C - let factorsAC = [f * -1 * (f * 1), -1 * 0, -1 * 1]; - factorsAC = factorsAC.map((v) => ((1 - v) / 2) * maxDist); - const distAC = factorsAC.reduce((a, b) => a + b, 0) / 3; - - expect(measureDistance(posA, posC, opts)).toBeCloseTo(distAC); - expect(measureDistance(posB, posA, opts), 'Commutatibility').toBeCloseTo(distAB); - expect(measureDistance(posC, posA, opts), 'Commutatibility').toBeCloseTo(distAC); - }); - describe('Distances for missing values', () => { - const f = 0.3; - const posMin = new MatchingSpacePosition([minVal, minVal, minVal]); - const posFract = new MatchingSpacePosition([f * minVal, f * minVal, f * maxVal]); - const posMissing = new MatchingSpacePosition([MISSING_VALUE, MISSING_VALUE, MISSING_VALUE]); - test('MissingValueDistanceMethod.Neutral', () => { - const opts: DistanceMeasurementOptions = { - metric: DistanceMetric.Manhattan, - missingValueOptions: { - missingValueMethod: MissingValueDistanceMethod.Neutral - } - }; - expect(measureDistance(posMin, posMissing, opts)).toBeCloseTo(0.5 * maxDist); - expect(measureDistance(posFract, posMissing, opts)).toBeCloseTo(0.5 * f * maxDist); - }); - test('MissingValueDistanceMethod.RelativeMaximum', () => { - const opts: DistanceMeasurementOptions = { - metric: DistanceMetric.Manhattan, - missingValueOptions: { - missingValueMethod: MissingValueDistanceMethod.RelativeMaximum - } - }; - expect(measureDistance(posMin, posMissing, opts)).toBeCloseTo(maxDist); - expect(measureDistance(posFract, posMissing, opts)).toBeCloseTo(0.5 * (1 + f) * maxDist); - }); - test('MissingValueDistanceMethod.AbsoluteMaximum', () => { - const opts: DistanceMeasurementOptions = { - metric: DistanceMetric.Manhattan, - missingValueOptions: { - missingValueMethod: MissingValueDistanceMethod.AbsoluteMaximum - } - }; - expect(measureDistance(posMin, posMissing, opts)).toBeCloseTo(maxDist); - expect(measureDistance(posFract, posMissing, opts)).toBeCloseTo(maxDist); - }); - test('No missing values for ref value', () => { - const opts: DistanceMeasurementOptions = { - metric: DistanceMetric.Manhattan, - missingValueOptions: { - missingValueMethod: MissingValueDistanceMethod.Neutral - } - }; - expect(() => measureDistance(posMissing, posMin, opts)).toThrow(); - }); - }); -}); -describe('matchingAlgorithm', () => { test('throw if there are duplicate questions', () => { const likertScale = 5; - const values = [0, 0.5, 1]; + const values = [1, 3, 5]; const {questions, voter, candidates, algorithm} = createMatchesAndEntities( - createLikertValues(likertScale, values), - [createLikertValues(likertScale, values)], + values, + [values, values], likertScale, - DistanceMetric.Manhattan, - MissingValueDistanceMethod.Neutral + DISTANCE_METRIC.Manhattan, + MISSING_VALUE_METHOD.Neutral ); // Add a question with the same id - questions.push(MultipleChoiceQuestion.fromLikert(questions[0].id, likertScale)); - expect(() => algorithm.match(questions, voter, candidates)).toThrow(); + questions.push(OrdinalQuestion.fromLikert({id: questions[0].id, scale: likertScale})); + expect(() => algorithm.match({questions, reference: voter, targets: candidates})).toThrow(); }); - test('throw if voter answers are missing', () => { + + test('skip missing reference questions', () => { const likertScale = 5; - const values = [0, 0.5, 1]; + const values = [1, 3, 5]; const {questions, voter, candidates, algorithm} = createMatchesAndEntities( - createLikertValues(likertScale, values), - [createLikertValues(likertScale, values)], + [undefined, 5, 5], + [values, values], likertScale, - DistanceMetric.Manhattan, - MissingValueDistanceMethod.Neutral - ); - // Delete all of the voter's answers - voter.answers = {}; - expect(() => algorithm.match(questions, voter, candidates)).toThrow(); - }); - test('projectToNormalizedSpace', () => { - const likertScale = 5; - const values = [0, 0.5, 1]; - const coords = values.map((v) => v * maxDist - maxVal); - const {questions, candidates, algorithm} = createMatchesAndEntities( - createLikertValues(likertScale, values), - [createLikertValues(likertScale, values)], - likertScale, - DistanceMetric.Manhattan, - MissingValueDistanceMethod.Neutral - ); - expect(algorithm.projectToNormalizedSpace(questions, candidates)[0].coordinates).toMatchObject( - coords + DISTANCE_METRIC.Manhattan, + MISSING_VALUE_METHOD.Neutral ); + const expected = (half + 0) / 2; // [3-5, 5-5] expect( - algorithm.projectToNormalizedSpace(questions.slice(0, 2), candidates)[0].coordinates, - 'Subset of answers based on question list' - ).toMatchObject(coords.slice(0, 2)); + algorithm.match({questions, reference: voter, targets: candidates})[0].distance + ).toBeCloseTo(expected); }); + describe('match with missing values', () => { const likertScale = 5; - const values = [0.25, 0.5, 1]; + const values = [1, 3, 5]; const valuesMissing = [MISSING_VALUE, MISSING_VALUE, MISSING_VALUE]; - test('MissingValueDistanceMethod.Neutral', () => { - const dist = (maxDist * (0.25 + 0 + 0.5)) / 3; + test('MISSING_VALUE_METHOD.Neutral', () => { const {questions, voter, candidates, algorithm} = createMatchesAndEntities( - createLikertValues(likertScale, values), + values, [valuesMissing], likertScale, - DistanceMetric.Manhattan, - MissingValueDistanceMethod.Neutral + DISTANCE_METRIC.Manhattan, + MISSING_VALUE_METHOD.Neutral ); - expect(algorithm.match(questions, voter, candidates)[0].distance).toBeCloseTo(dist); + const expected = (half + 0 + half) / 3; // [1-3, 3-3, 5-3] + expect( + algorithm.match({questions, reference: voter, targets: candidates})[0].distance + ).toBeCloseTo(expected); }); - test('MissingValueDistanceMethod.Neutral', () => { - const dist = (maxDist * (0.75 + 0.5 + 1)) / 3; + test('MISSING_VALUE_METHOD.RelativeMaximum', () => { const {questions, voter, candidates, algorithm} = createMatchesAndEntities( - createLikertValues(likertScale, values), + values, [valuesMissing], likertScale, - DistanceMetric.Manhattan, - MissingValueDistanceMethod.RelativeMaximum + DISTANCE_METRIC.Manhattan, + MISSING_VALUE_METHOD.RelativeMaximum ); - expect(algorithm.match(questions, voter, candidates)[0].distance).toBeCloseTo(dist); + const expected = (full + half + full) / 3; // [1-5, 3-5, 5-1] + expect( + algorithm.match({questions, reference: voter, targets: candidates})[0].distance + ).toBeCloseTo(expected); }); - test('MissingValueDistanceMethod.AbsoluteMaximum', () => { - const dist = maxDist; - const {questions, voter, candidates, algorithm} = createMatchesAndEntities( - createLikertValues(likertScale, values), - [valuesMissing], - likertScale, - DistanceMetric.Manhattan, - MissingValueDistanceMethod.AbsoluteMaximum - ); - expect(algorithm.match(questions, voter, candidates)[0].distance).toBeCloseTo(dist); + }); + + test('different distance metric', () => { + const likertScale = 5; + const voterValues = [1, 2, 3, 4, 2]; + const candValues = [1, 5, 5, 2, 3]; + const {questions, voter, candidates, algorithm} = createMatchesAndEntities( + voterValues, + [candValues, candValues], + likertScale, + DISTANCE_METRIC.Directional, + MISSING_VALUE_METHOD.Neutral + ); + const expected = + (0 + // 1-1 + 0.75 * full + // 2-5 + half + // 3-5 + 0.625 * full + // 4-2 + half) / // 2-3 + questions.length; + expect( + algorithm.match({questions, reference: voter, targets: candidates})[0].distance + ).toBeCloseTo(expected); + }); + + test('categorical questions', () => { + const categorical2 = new CategoricalQuestion({ + id: 'categorical2', + values: [{id: 'no'}, {id: 'yes'}] + }); + const categorical4 = new CategoricalQuestion({ + id: 'categorical4', + values: [{id: 'a'}, {id: 'b'}, {id: 'c'}, {id: 'd'}] + }); + const questions = [categorical2, categorical4]; + const voterValues = ['no', 'a']; + const voter = createVoter(questions, voterValues); + const disagreeValues = ['yes', 'c']; + const missingValues = [MISSING_VALUE, MISSING_VALUE]; + const candidates = createCandidates(questions, [disagreeValues, missingValues, voterValues]); + const algorithm = new MatchingAlgorithm({ + distanceMetric: DISTANCE_METRIC.Manhattan, + missingValueOptions: {method: MISSING_VALUE_METHOD.RelativeMaximum} }); + const matches = algorithm.match({questions, reference: voter, targets: candidates}); + const expected0 = (full + (full * 2) / 4) / questions.length; // binary disagreement, 4-choice disagreement + const expected1 = full; // all missing values + const expected2 = 0; // perfect agreement + expect(matches.find((m) => m.entity === candidates[0])?.distance).toBeCloseTo(expected0); + expect(matches.find((m) => m.entity === candidates[1])?.distance).toBeCloseTo(expected1); + expect(matches.find((m) => m.entity === candidates[2])?.distance).toBeCloseTo(expected2); }); - const likertScales = [3, 4, 5, 10]; - describe.each(likertScales)('match with Likert %d', (likertScale: number) => { - test('DistanceMetric.Manhattan', () => { - const voterValues = [0.5, 1, 0]; - const candValues = [0, 0.5, 1]; - const dist = (maxDist * (0.5 + 0.5 + 1)) / 3; - const {questions, voter, candidates, algorithm} = createMatchesAndEntities( - createLikertValues(likertScale, voterValues), - [createLikertValues(likertScale, candValues)], - likertScale, - DistanceMetric.Manhattan, - MissingValueDistanceMethod.Neutral - ); - expect(algorithm.match(questions, voter, candidates)[0].distance).toBeCloseTo(dist); + + test('mixed question types', () => { + const likert5 = OrdinalQuestion.fromLikert({id: 'likert5', scale: 5}); + const likert7 = OrdinalQuestion.fromLikert({id: 'likert7', scale: 7}); + const categorical2 = new CategoricalQuestion({ + id: 'categorical2', + values: [{id: 'no'}, {id: 'yes'}] }); - test('DistanceMetric.Directional', () => { - const voterValues = [0.5, 1, 0]; - const candValues = [0, 0.5, 1]; - // Calculate first with factors on a scale from -1 to 1 (disagree to agree) - // where minVal = -1 and maxVal = 1 - let factors = [0 * -1, 1 * 0, -1 * 1]; - // Convert to distance and scale with maxDist - factors = factors.map((v) => ((1 - v) / 2) * maxDist); - // Add up and divide by dims - const dist = factors.reduce((a, b) => a + b, 0) / 3; - const {questions, voter, candidates, algorithm} = createMatchesAndEntities( - createLikertValues(likertScale, voterValues), - [createLikertValues(likertScale, candValues)], - likertScale, - DistanceMetric.Directional, - MissingValueDistanceMethod.Neutral - ); - expect(algorithm.match(questions, voter, candidates)[0].distance).toBeCloseTo(dist); + const categorical4 = new CategoricalQuestion({ + id: 'categorical4', + values: [{id: 'a'}, {id: 'b'}, {id: 'c'}, {id: 'd'}] + }); + const questions = [likert5, likert7, categorical2, categorical4]; + const voterValues = [likert5.values[0].id, likert7.values[5].id, 'no', 'c']; + const voter = createVoter(questions, voterValues); + const candAValues = [undefined, likert7.values[1].id, 'yes', 'c']; + const candBValues = [likert5.values[3].id, likert7.values[6].id, 'no', 'a']; + const candidates = createCandidates(questions, [candAValues, candBValues]); + const algorithm = new MatchingAlgorithm({ + distanceMetric: DISTANCE_METRIC.Manhattan, + missingValueOptions: {method: MISSING_VALUE_METHOD.RelativeMaximum} }); + const matches = algorithm.match({questions, reference: voter, targets: candidates}); + const expectedA = + (full + // likert5: 1-missing=>5 + ((5 - 1) / 6) * full + // likert7: 6-2 + full + // categorical2: no-yes + 0) / // categorical4: c-c + questions.length; + const expectedB = + (0.75 * full + // likert5: 1-4 + ((6 - 5) / 6) * full + // likert7: 6-7 + 0 + // categorical2: no-no + (2 / 4) * full) / // categorical4: c-a + questions.length; + expect(matches.find((m) => m.entity === candidates[0])?.distance).toBeCloseTo(expectedA); + expect(matches.find((m) => m.entity === candidates[1])?.distance).toBeCloseTo(expectedB); }); + describe('submatches', () => { - test('createSubspaces', () => { - const numQuestions = 5; - const numSubset = 3; - const questions = createQuestions(numQuestions, 5); - const otherQuestions = createQuestions(numQuestions, 5); - const subsetA = questions.slice(0, numSubset); - const subsetB = [...subsetA, ...otherQuestions.slice(2)]; - expect( - createSubspace(questions, questions.slice()).maxDistance, - 'subset is fully included' - ).toBeCloseTo(numQuestions); - expect( - createSubspace(questions, otherQuestions).maxDistance, - 'subset is fully excluded' - ).toBeCloseTo(0); - expect( - createSubspace(questions, subsetA).maxDistance, - 'subset is partly included' - ).toBeCloseTo(numSubset); - expect( - createSubspace(questions, subsetB).maxDistance, - 'subset is partly included and partly excluded' - ).toBeCloseTo(numSubset); - }); test('match with subQuestionGroups', () => { const likertScale = 5; - const voterValues = [0, 0, 0, 0, 0.5]; - const candValues = [0, 0, 1, 1, 1]; + const voterValues = [1, 1, 1, 1, 3]; + const candValues = [1, 1, 5, 4, 5]; const {questions, voter, candidates, algorithm} = createMatchesAndEntities( - createLikertValues(likertScale, voterValues), - [createLikertValues(likertScale, candValues)], + voterValues, + [candValues], likertScale, - DistanceMetric.Manhattan, - MissingValueDistanceMethod.Neutral + DISTANCE_METRIC.Manhattan, + MISSING_VALUE_METHOD.Neutral ); + const fullDist = (0 + 0 + full + 0.75 * full + half) / questions.length; // 1-1, 1-1, 1-5, 1-4, 3-5 const subsetA = {matchableQuestions: questions.slice(0, 1)}; - const subsetADist = 0; + const subsetADist = 0; // 1-1 const subsetB = {matchableQuestions: questions.slice(2, 3)}; - const subsetBDist = maxDist; + const subsetBDist = full; // 1-5 const subsetC = {matchableQuestions: questions.slice(0, 4)}; - const subsetCDist = maxDist * (1 - 0.5); + const subsetCDist = (0 + 0 + full + 0.75 * full) / 4; // 1-1, 1-1, 1-5, 1-4 const questionGroups = [subsetA, subsetB, subsetC]; - const match = algorithm.match(questions, voter, candidates, {questionGroups})[0]; - expect(match.subMatches?.length).toBeCloseTo(questionGroups.length); - expect(match.subMatches?.[0].distance).toBeCloseTo(subsetADist); - expect(match.subMatches?.[0].questionGroup).toBe(subsetA); - expect(match.subMatches?.[1].distance).toBeCloseTo(subsetBDist); - expect(match.subMatches?.[2].distance).toBeCloseTo(subsetCDist); - describe('subMatch for questions the voter has not answered should be zero', () => { - const likertScale = 5; - const voterValues = [0, 0, 1]; - const candValues = [0, 0, 1]; - const {questions, voter, candidates, algorithm} = createMatchesAndEntities( - createLikertValues(likertScale, voterValues), - [createLikertValues(likertScale, candValues)], - likertScale, - DistanceMetric.Manhattan, - MissingValueDistanceMethod.Neutral - ); - // A subset that includes only the question the voter has not answered - const subset = {matchableQuestions: questions.slice(-1)}; - // Remove the voter's answer to the question - const lastQuestionId = subset.matchableQuestions[0].id; - delete voter.answers[lastQuestionId]; - expect(voter.answers[lastQuestionId]).toBeUndefined(); - // Remove the question from the matching questions as well - const questionsMinusLast = [...questions.slice(0, -1)]; - const match = algorithm.match(questionsMinusLast, voter, candidates, { - questionGroups: [subset] - })[0]; - expect(match.subMatches?.[0].distance).toBeCloseTo(0); - expect(match.subMatches?.[0].questionGroup).toBe(subset); - }); + const match = algorithm.match({ + questions, + reference: voter, + targets: candidates, + options: {questionGroups} + })[0]; + expect(match.distance, 'to have global distance').toBeCloseTo(fullDist); + expect(match.subMatches?.length, 'to have submatches for all subgroups').toBe( + questionGroups.length + ); + expect(match.subMatches?.[0].distance, 'to have subgroup a distance').toBeCloseTo( + subsetADist + ); + expect(match.subMatches?.[0].questionGroup, 'to contain reference to subgroup').toBe(subsetA); + expect(match.subMatches?.[1].distance, 'to have subgroup b distance').toBeCloseTo( + subsetBDist + ); + expect(match.subMatches?.[2].distance, 'to have subgroup c distance').toBeCloseTo( + subsetCDist + ); + }); + test('subMatch for questions the voter has not answered should be zero', () => { + const likertScale = 5; + const voterValues = [undefined, 1, 1]; + const candValues = [1, 3, 5]; + const {questions, voter, candidates, algorithm} = createMatchesAndEntities( + voterValues, + [candValues], + likertScale, + DISTANCE_METRIC.Manhattan, + MISSING_VALUE_METHOD.RelativeMaximum + ); + // A subset that includes only the question the voter has not answered + const questionGroups = [{matchableQuestions: questions.slice(0, 1)}]; + const match = algorithm.match({ + questions: questions.slice(1), + reference: voter, + targets: candidates, + options: {questionGroups} + })[0]; + expect(match.subMatches?.[0].distance).toBeCloseTo(half); + expect(match.subMatches?.[0].questionGroup).toBe(questionGroups[0]); }); }); -}); - -/********************************************************************** - * Helper functions - **********************************************************************/ -/** - * Create dummy matches for testing. - * - * @param voterAnswers Array of voter answers - * @param candidateAnswers Array of Arrays of candidate answers - * @param likertScale The likert scale, e.g. 5 - * @param distanceMetric The DistanceMetric to use - * @param missingValueMethod The MissingValueDistanceMethod - * @returns A dict of all generated objects - */ -function createMatchesAndEntities( - voterAnswers: (number | undefined)[], - candidateAnswers: (number | undefined)[][], - likertScale: number, - distanceMetric: DistanceMetric, - missingValueMethod: MissingValueDistanceMethod -): { - questions: MultipleChoiceQuestion[]; - voter: Candidate; - candidates: Candidate[]; - algorithm: MatchingAlgorithm; - matches: Match[]; -} { - const numQuestions = voterAnswers.length; - // Create dummy questions - const questions = createQuestions(numQuestions, likertScale); - // Create a Candidate to represent the voter - const voter = createVoter(questions, voterAnswers); - // Create dummy candidates - const candidates = createCandidates(questions, candidateAnswers); - // Matching algorithm - const algorithm = new MatchingAlgorithm({ - distanceMetric, - missingValueOptions: {missingValueMethod} + test('question weights', () => { + const likertScale = 5; + const voterValues = [1, 3, 5]; + const candValues = [1, 1, 1]; + const {questions, voter, candidates, algorithm} = createMatchesAndEntities( + voterValues, + [candValues], + likertScale, + DISTANCE_METRIC.Manhattan, + MISSING_VALUE_METHOD.Neutral + ); + const weights = [0.54234, 4.131231, 97.1134]; + const questionWeights = Object.fromEntries(questions.map((q, i) => [q.id, weights[i]])); + const expected = + (weights[0] * 0 + // 1-1 + weights[1] * half + // 3-1 + weights[2] * full) / // 5-1 + weights.reduce((acc, v) => acc + v, 0); + const match = algorithm.match({ + questions, + reference: voter, + targets: candidates, + options: {questionWeights} + })[0]; + expect(match.distance).toBeCloseTo(expected); }); - // Get matches - const matches = algorithm.match(questions, voter, candidates); - return { - questions, - voter, - candidates, - algorithm, - matches - }; -} - -/** - * A dummy candidate object for matching. - */ -class Candidate implements HasMatchableAnswers { - constructor(public answers: AnswerDict) {} -} - -/** - * Convert normalized values to Likert-scale values. - * - * @param scale The likert scale, e.g. 5 - * @param values The normalised values - * @returns Array of values - */ -function createLikertValues(scale: number, values: UnsignedNormalizedDistance[]) { - return values.map((v) => 1 + v * (scale - 1)); -} - -/** - * Create dummy answers. - * - * @param questions Question list - * @param answerValues The answer values - * @returns An answer dict - */ -function createAnswers( - questions: MatchableQuestion[], - answerValues: (number | undefined)[] -): AnswerDict { - const answers = {} as AnswerDict; - for (let i = 0; i < questions.length; i++) { - answers[questions[i].id] = {value: answerValues[i]}; - } - return answers; -} - -/** - * Create dummy questions. - * - * @param numQuestions Number of questions to creata - * @param likertScale The likert scale, e.g. 5 - * @returns Array of questions - */ -function createQuestions(numQuestions: number, likertScale: number): MultipleChoiceQuestion[] { - return Array.from({length: numQuestions}, (_, i) => - MultipleChoiceQuestion.fromLikert(`qst${i}`, likertScale) - ); -} - -/** - * Create dummy candidates - * - * @param questions The dummy questions - * @param candidateAnswers Array of Arrays of candidate answers - * @returns Array of candidates - */ -function createCandidates( - questions: MultipleChoiceQuestion[], - candidateAnswers: (number | undefined)[][] -): Candidate[] { - return candidateAnswers.map((o) => new Candidate(createAnswers(questions, o))); -} - -/** - * Create a dummy Candidate to represent the voter - * @param questions The dummy questions - * @param voterAnswers Array of voter answers - * @returns A candidate - */ -function createVoter( - questions: MultipleChoiceQuestion[], - voterAnswers: (number | undefined)[] -): Candidate { - return new Candidate(createAnswers(questions, voterAnswers)); -} +}); diff --git a/frontend/src/lib/voter/vaa-matching/tests/core.test.ts b/frontend/src/lib/voter/vaa-matching/tests/core.test.ts deleted file mode 100644 index b01ca45aa..000000000 --- a/frontend/src/lib/voter/vaa-matching/tests/core.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import {NORMALIZED_DISTANCE_EXTENT, type SignedNormalizedDistance} from '../src/distance/'; -import {MISSING_VALUE} from '../src/missingValue'; -import {MatchingSpace, MatchingSpacePosition, type MatchingSpaceCoordinate} from '../src/space'; - -// For convenience -const maxVal: SignedNormalizedDistance = NORMALIZED_DISTANCE_EXTENT / 2; -const minVal: SignedNormalizedDistance = -maxVal; -const weights = [1, 2, 3]; -const ms = new MatchingSpace(weights); -const coords: MatchingSpaceCoordinate[] = [minVal, maxVal, MISSING_VALUE]; -const wrongCoords: MatchingSpaceCoordinate[] = [minVal, maxVal, MISSING_VALUE, maxVal]; - -test('MatchingSpace', () => { - expect(ms.dimensions).toBe(3); - expect(ms.maxDistance).toBe(6); -}); - -test('MatchingSpacePosition', () => { - const position = new MatchingSpacePosition(coords, ms); - expect(position.dimensions).toBe(3); - expect(() => new MatchingSpacePosition(wrongCoords, ms)).toThrowError(); -}); diff --git a/frontend/src/lib/voter/vaa-matching/tests/distance.test.ts b/frontend/src/lib/voter/vaa-matching/tests/distance.test.ts new file mode 100644 index 000000000..c9f55fc6b --- /dev/null +++ b/frontend/src/lib/voter/vaa-matching/tests/distance.test.ts @@ -0,0 +1,239 @@ +import {COORDINATE} from 'vaa-shared'; +import {MockQuestion} from './utils'; +import {createSubspace, MatchingSpace, Position} from '../src/space'; +import { + DISTANCE_METRIC, + euclideanSubdimWeight, + basicDivision, + euclideanSum, + basicSum, + directionalKernel, + absoluteKernel, + manhattanDistance, + directionalDistance, + euclideanDistance +} from '../src/distance/metric'; +import {measureDistance} from '../src/distance/measure'; +import type {DistanceMeasurementOptions} from '../src/distance'; +import {MISSING_VALUE_METHOD} from '../src/missingValue'; + +// For convenience +const neutral = COORDINATE.Neutral; +const max = COORDINATE.Max; +const min = COORDINATE.Min; +const half = COORDINATE.Extent / 2; +const full = COORDINATE.Extent; +const halfMin = neutral + 0.5 * (min - neutral); // I.e. somewhat disagree +const halfMax = neutral + 0.5 * (max - neutral); // I.e. somewhat agree + +describe('metric: kernels', () => { + test('absoluteKernel', () => { + expect(absoluteKernel(-max, max)).toBeCloseTo(2 * Math.abs(max)); + expect(absoluteKernel(max, -max), 'to be commutable').toBeCloseTo(2 * Math.abs(max)); + expect(absoluteKernel(max, max)).toBeCloseTo(0); + }); + test('directionalKernel', () => { + expect(directionalKernel(min, max)).toBeCloseTo(full); + expect(directionalKernel(max, min), 'to be commutable').toBeCloseTo(full); + expect(directionalKernel(neutral, max), 'to be half when either value is neutral').toBeCloseTo( + half + ); + expect( + directionalKernel(min, neutral), + 'to be half when either value is neutral 2' + ).toBeCloseTo(half); + expect( + directionalKernel(neutral, halfMax), + 'to be half when either value is neutral 3' + ).toBeCloseTo(half); + expect( + directionalKernel(neutral, neutral), + 'to be half when either value is neutral 3' + ).toBeCloseTo(half); + expect(directionalKernel(halfMin, halfMax), 'to scale with assumed uncertainty').toBeCloseTo( + 0.625 * full + ); + expect( + directionalKernel(halfMax, halfMin), + 'to scale with assumed uncertainty and commute' + ).toBeCloseTo(0.625 * full); + expect(directionalKernel(min, halfMax), 'to scale with assumed uncertainty 2').toBeCloseTo( + 0.75 * full + ); + expect(directionalKernel(max, halfMin), 'to scale with assumed uncertainty 3').toBeCloseTo( + 0.75 * full + ); + }); + test('basicSum', () => { + expect(basicSum([1, 2, 3])).toBeCloseTo(6); + }); + test('euclideanSum', () => { + expect(euclideanSum([1, 2, 3])).toBeCloseTo(Math.sqrt(1 + 2 ** 2 + 3 ** 2)); + }); + test('basicDivision', () => { + expect(basicDivision(5)).toBeCloseTo(1 / 5); + }); + test('euclideanSubdimWeight', () => { + const subdimWeight = euclideanSubdimWeight(4); + expect(subdimWeight).toBeCloseTo(1 / Math.sqrt(4)); + expect( + euclideanSum([2, 3, subdimWeight, subdimWeight, subdimWeight, subdimWeight]), + 'euclidean sum of subdimensions to equal 1' + ).toBeCloseTo(euclideanSum([2, 3, 1])); + }); +}); + +describe('metric: distance', () => { + const metrics = Object.entries(DISTANCE_METRIC).map(([name, metric]) => ({name, metric})); + const space = new MatchingSpace({shape: 3}); + const posMin = new Position({coordinates: [min, min, min], space}); + const posMax = new Position({coordinates: [max, max, max], space}); + const posMissing = new Position({coordinates: [min, min, undefined], space}); + const posAllMissing = new Position({coordinates: [undefined, undefined, undefined], space}); + const space2D = new MatchingSpace({shape: 2}); + const posMin2D = new Position({coordinates: [min, min], space: space2D}); + const posMax2D = new Position({coordinates: [max, max], space: space2D}); + + test.each(metrics)('extreme distance with metric $name', ({metric}) => { + expect(metric({a: posMin, b: posMax})).toBeCloseTo(COORDINATE.Extent); + expect(metric({b: posMin, a: posMax}), 'to be commutable').toBeCloseTo(COORDINATE.Extent); + }); + test.each(metrics)('disallow missing with metric $name', ({metric}) => { + expect(() => metric({a: posMin, b: posMissing}), 'to disallow missing by default').toThrow(); + expect(() => metric({a: posMissing, b: posMin}), 'to disallow missing by default 2').toThrow(); + expect( + () => metric({a: posMissing, b: posMin, allowMissing: false}), + 'to explicitly disallow missing' + ).toThrow(); + }); + test.each(metrics)('disregard dimensions with missing values with metric $name', ({metric}) => { + expect( + metric({a: posMax, b: posMissing, allowMissing: true}), + 'to skip the third dimension when calculating distance' + ).toBeCloseTo(metric({a: posMin2D, b: posMax2D})); + expect(metric({a: posMissing, b: posMax, allowMissing: true}), 'to be commutable').toBeCloseTo( + metric({a: posMin2D, b: posMax2D}) + ); + }); + test.each(metrics)( + 'return half extent when all dimensions are missing with metric $name', + ({metric}) => { + expect(metric({a: posMin, b: posAllMissing, allowMissing: true})).toBeCloseTo(half); + } + ); + + test('manhattanDistance', () => { + const weights = [1.3121, 5.1324, 9.123, 13.14, 0]; + const shape = [1, 1, 1, 3, 1]; + const space = new MatchingSpace({shape, weights}); + const a = new Position({coordinates: [min, min, neutral, [max, max, max], max], space}); + const b = new Position({coordinates: [max, min, max, [max, min, neutral], min], space}); + let expected = + weights[0] * full + // min-max + weights[1] * 0 + // min-min + weights[2] * half + // neutral-max + (weights[3] * (0 + full + half)) / 3 + // [max, max, max]-[max, min, neutral] + weights[4] * full; // but weight is zero + // divide expected by sum of weights + expected /= weights.reduce((acc, v) => acc + v, 0); + expect(manhattanDistance({a, b})).toBeCloseTo(expected); + expect(manhattanDistance({a: b, b: a}), 'to be commutable').toBeCloseTo(expected); + }); + + test('directionalDistance', () => { + const weights = [1.3121, 5.1324, 9.123, 13.14, 43.745]; + const shape = [1, 1, 1, 3, 1]; + const space = new MatchingSpace({shape, weights}); + const a = new Position({coordinates: [min, min, neutral, [max, max, max], halfMin], space}); + const b = new Position({coordinates: [max, halfMin, max, [max, min, neutral], halfMax], space}); + let expected = + weights[0] * full + // min-max + weights[1] * 0.25 * full + // min-halfMin + weights[2] * half + // neutral-max + (weights[3] * (0 + full + half)) / 3 + // [max, max, max]-[max, min, neutral] + weights[4] * 0.625 * full; // halfMin-halfMax + // divide expected by sum of weights + expected /= weights.reduce((acc, v) => acc + v, 0); + expect(directionalDistance({a, b})).toBeCloseTo(expected); + expect(directionalDistance({a: b, b: a}), 'to be commutable').toBeCloseTo(expected); + }); + + test('euclideanDistance', () => { + const weights = [1.3121, 5.1324, 9.123, 13.14, 0]; + const shape = [1, 1, 1, 3, 1]; + const space = new MatchingSpace({shape, weights}); + const a = new Position({coordinates: [min, min, neutral, [max, max, max], max], space}); + const b = new Position({coordinates: [max, min, max, [max, min, neutral], min], space}); + const expectedDistances = [ + weights[0] * full, // min-max + weights[1] * 0, // min-min + weights[2] * half, // neutral-max + // the subdims need to be expanded for the summing to work correctly + (weights[3] * 0) / Math.sqrt(3), // [max, max, max]-[max, min, neutral] 0 + (weights[3] * full) / Math.sqrt(3), // [max, max, max]-[max, min, neutral] 1 + (weights[3] * half) / Math.sqrt(3), // [max, max, max]-[max, min, neutral] 2 + weights[4] * full // but weight is zero + ]; + // foot of sum of squares + let expected = Math.sqrt(expectedDistances.reduce((acc, v) => acc + v ** 2, 0)); + // divide expected by root of sum of squares weights + expected /= Math.sqrt(weights.reduce((acc, v) => acc + v ** 2, 0)); + expect(euclideanDistance({a, b})).toBeCloseTo(expected); + expect(euclideanDistance({a: b, b: a}), 'to be commutable').toBeCloseTo(expected); + }); +}); + +describe('metric: measure', () => { + const questions = Array.from({length: 4}, () => void 0).map(() => new MockQuestion(1)); + const space = MatchingSpace.fromQuestions({questions}); + const subspaces = [ + createSubspace({questions, subset: questions.slice(2)}), + createSubspace({questions, subset: questions.slice(0, 3)}) + ]; + const minPos = new Position({coordinates: [min, min, min, min], space}); + const otherPos = new Position({coordinates: [min, neutral, max, undefined], space}); + const options: DistanceMeasurementOptions = { + metric: DISTANCE_METRIC.Manhattan, + missingValueOptions: { + method: MISSING_VALUE_METHOD.RelativeMaximum + } + }; + + test('missing values', () => { + const expectedA = (0 + half + full + full) / 4; + expect( + measureDistance({reference: minPos, target: otherPos, options}), + 'to impute missing correctly' + ).toBeCloseTo(expectedA); + const expectedB = (0 + half + full + 0) / 3; + expect( + measureDistance({ + reference: otherPos, + target: minPos, + options: {...options, allowMissingReference: true} + }), + 'to skip the missing reference dimension' + ).toBeCloseTo(expectedB); + expect( + () => measureDistance({reference: otherPos, target: minPos, options}), + 'to disallow missing reference values by default' + ).toThrow(); + }); + + test('subspaces', () => { + const result = measureDistance({reference: minPos, target: otherPos, options, subspaces}); + const expectedGlobal = (0 + half + full + full) / 4; + const expectedSubspaceA = (full + full) / 2; // The last two dimensions only + const expectedSubspaceB = (0 + half + full) / 3; // The first three dimensions only + expect(result.global, 'to contain global distance').toBeCloseTo(expectedGlobal); + expect(result.subspaces.length, 'to contain subspace distances for all subspaces').toEqual( + subspaces.length + ); + expect(result.subspaces[0], 'to correctly compute subspace distance 1').toBeCloseTo( + expectedSubspaceA + ); + expect(result.subspaces[1], 'to correctly compute subspace distance 2').toBeCloseTo( + expectedSubspaceB + ); + }); +}); diff --git a/frontend/src/lib/voter/vaa-matching/tests/missingValue.test.ts b/frontend/src/lib/voter/vaa-matching/tests/missingValue.test.ts new file mode 100644 index 000000000..27d083d18 --- /dev/null +++ b/frontend/src/lib/voter/vaa-matching/tests/missingValue.test.ts @@ -0,0 +1,102 @@ +import {COORDINATE} from 'vaa-shared'; +import { + imputeMissingPosition, + imputeMissingValue, + MISSING_VALUE_BIAS, + MISSING_VALUE_METHOD +} from '../src/missingValue'; +import {MatchingSpace, Position} from '../src/space'; + +// For convenience +const neutral = COORDINATE.Neutral; +const max = COORDINATE.Max; +const min = COORDINATE.Min; + +test('imputeMissingValue', () => { + expect( + imputeMissingValue({ + reference: neutral, + options: { + method: MISSING_VALUE_METHOD.Neutral + } + }), + 'neutral method to disregard reference value' + ).toEqual(neutral); + expect( + imputeMissingValue({ + reference: max, + options: { + method: MISSING_VALUE_METHOD.Neutral + } + }), + 'neutral method to disregard reference value 2' + ).toEqual(neutral); + expect( + imputeMissingValue({ + reference: neutral, + options: { + method: MISSING_VALUE_METHOD.Neutral, + bias: MISSING_VALUE_BIAS.Positive + } + }), + 'neutral method to disregard bias' + ).toEqual(neutral); + expect( + imputeMissingValue({ + reference: neutral, + options: { + method: MISSING_VALUE_METHOD.RelativeMaximum, + bias: MISSING_VALUE_BIAS.Positive + } + }), + 'RelativeMaximum method to respect bias' + ).toEqual(max); + expect( + imputeMissingValue({ + reference: neutral, + options: { + method: MISSING_VALUE_METHOD.RelativeMaximum, + bias: MISSING_VALUE_BIAS.Negative + } + }), + 'RelativeMaximum method to respect bias 2' + ).toEqual(min); + expect( + imputeMissingValue({ + reference: max, + options: { + method: MISSING_VALUE_METHOD.RelativeMaximum, + bias: MISSING_VALUE_BIAS.Positive + } + }), + 'RelativeMaximum method to disregard bias if reference is not neutral' + ).toEqual(min); + expect( + imputeMissingValue({ + reference: neutral + 0.5 * (max - neutral), + options: { + method: MISSING_VALUE_METHOD.RelativeMaximum, + bias: MISSING_VALUE_BIAS.Positive + } + }), + 'RelativeMaximum method to disregard bias if reference is not neutral 2' + ).toEqual(min); +}); + +test('imputeMissingPosition', () => { + const space = new MatchingSpace({shape: [1, 1, 1, 3]}); + const refCoords = [neutral, min, max, [neutral, min, max]]; + const tgtCoords = [undefined, min, undefined, [neutral, undefined, max]]; + const expected = [max, min, min, [neutral, max, max]]; + expect( + imputeMissingPosition({ + reference: new Position({coordinates: refCoords, space}), + target: new Position({coordinates: tgtCoords, space}), + options: { + method: MISSING_VALUE_METHOD.RelativeMaximum, + bias: MISSING_VALUE_BIAS.Positive + } + }).coordinates, + 'all coordinates to be imputed' + ).toEqual(expected); +}); diff --git a/frontend/src/lib/voter/vaa-matching/tests/question.test.ts b/frontend/src/lib/voter/vaa-matching/tests/question.test.ts new file mode 100644 index 000000000..6ff5fcee9 --- /dev/null +++ b/frontend/src/lib/voter/vaa-matching/tests/question.test.ts @@ -0,0 +1,38 @@ +import {COORDINATE, MISSING_VALUE} from 'vaa-shared'; +import {CategoricalQuestion, OrdinalQuestion} from '../src/question'; + +describe('categoricalQuestion', () => { + test('binary question and missing values', () => { + const values = [{id: 'no'}, {id: 'yes'}]; + const question = new CategoricalQuestion({id: 'q1', values}); + expect(question.normalizeValue('no')).toBe(COORDINATE.Min); + expect(question.normalizeValue('yes')).toBe(COORDINATE.Max); + expect(question.normalizeValue(undefined)).toBe(MISSING_VALUE); + expect(() => question.normalizeValue('missing id')).toThrow(); + }); + test('multiple dimensions', () => { + const values = [{id: 'red'}, {id: 'blue'}, {id: 'green'}]; + const question = new CategoricalQuestion({id: 'q1', values}); + expect(question.normalizeValue('red')).toEqual([ + COORDINATE.Max, + COORDINATE.Min, + COORDINATE.Min + ]); + expect(question.normalizeValue('green')).toEqual([ + COORDINATE.Min, + COORDINATE.Min, + COORDINATE.Max + ]); + expect(question.normalizeValue(undefined)).toEqual(values.map(() => MISSING_VALUE)); + }); +}); + +test('ordinalQuestion', () => { + const question = OrdinalQuestion.fromLikert({id: 'q1', scale: 5}); + const {values} = question; + expect(question.normalizeValue(values[0].id)).toBe(COORDINATE.Min); + expect(question.normalizeValue(values[2].id)).toBe(COORDINATE.Neutral); + expect(question.normalizeValue(values[4].id)).toBe(COORDINATE.Max); + expect(question.normalizeValue(undefined)).toBe(MISSING_VALUE); + expect(() => question.normalizeValue('missing id')).toThrow(); +}); diff --git a/frontend/src/lib/voter/vaa-matching/tests/space.test.ts b/frontend/src/lib/voter/vaa-matching/tests/space.test.ts new file mode 100644 index 000000000..ada8062d6 --- /dev/null +++ b/frontend/src/lib/voter/vaa-matching/tests/space.test.ts @@ -0,0 +1,111 @@ +import {MockQuestion} from './utils'; +import { + coordinatesShape, + createSubspace, + equalShapes, + flatten, + MatchingSpace, + Position, + reshape +} from '../src/space'; + +test('Shape', () => { + expect(coordinatesShape([1, 1, undefined]), 'flat shape with').toEqual([1, 1, 1]); + expect(coordinatesShape([1, undefined, [1, 1, 1]]), 'shape with subdimension').toEqual([1, 1, 3]); + expect(flatten([1, 1, undefined]), 'flattened flat shape').toEqual([1, 1, undefined]); + expect(flatten([1, undefined, [1, 1, 1]]), 'flattened shape with subdimension').toEqual([ + 1, + undefined, + 1, + 1, + 1 + ]); + expect(equalShapes([1, 1, 1], [1, 1, 1]), 'equal shapes').toBe(true); + expect(equalShapes([1, 1, 3], [1, 1, 3]), 'equal shapes').toBe(true); + expect(equalShapes([1, 1, 1], [1, 1, 3]), 'different shapes').toBe(false); + expect(equalShapes([1, 1, 1], [1, 1, 1, 1]), 'different shapes').toBe(false); + expect(reshape({flat: [1, 2, 3, 4, 5, 6], shape: [1, 1, 3, 1]}), 'reshape').toEqual([ + 1, + 2, + [3, 4, 5], + 6 + ]); + const coords = [1, undefined, [1, 1, 1]]; + expect( + reshape({flat: flatten(coords), shape: coordinatesShape(coords)}), + 'reshape flattened to original shape' + ).toEqual(coords); + expect( + () => reshape({flat: [1, 2, 3, 4, 5, 6], shape: [1, 1, 3, 1, 2]}), + 'illegal reshape' + ).toThrow(); +}); + +test('MatchingSpace', () => { + expect(new MatchingSpace({shape: [1, 1, 3]}).shape, 'fully defined shape').toEqual([1, 1, 3]); + expect(new MatchingSpace({shape: 3}).shape, 'shape as number of dimensions').toEqual([1, 1, 1]); + expect(new MatchingSpace({shape: [1, 1, 3]}).weights, 'uniform weights by default').toEqual([ + 1, 1, 1 + ]); + expect(new MatchingSpace({shape: 3}).weights, 'uniform weights by default').toEqual([1, 1, 1]); + expect( + new MatchingSpace({shape: 3, weights: [1, 2, 3]}).weights, + 'fully defined weights' + ).toEqual([1, 2, 3]); + expect( + () => new MatchingSpace({shape: 3, weights: [1, 2, 3, 4]}), + 'incompatible weights and shape' + ).toThrow(); + const dimensions = [1, 1, 3]; + const weights = [1, 2, 3]; + const questions = dimensions.map((i) => new MockQuestion(i)); + describe('MatchingSpace.fromQuestions', () => { + const questionWeights = Object.fromEntries(questions.map((q, i) => [q.id, weights[i]])); + delete questionWeights[2]; + const expected = [weights[0], weights[1], 1]; // the last should default to one + expect(MatchingSpace.fromQuestions({questions}).shape, 'to have correct dimensions').toEqual( + dimensions + ); + expect( + MatchingSpace.fromQuestions({questions, questionWeights}).weights, + 'to have specified weights defaulting to one' + ).toEqual(expected); + }); + const space = new MatchingSpace({shape: 3, weights: [1, 1, 1]}); + expect( + space.isCompatible(new MatchingSpace({shape: 3, weights: [1, 2, 3]})), + 'compatible with different weights' + ).toBe(true); + expect( + space.isCompatible(new MatchingSpace({shape: 4})), + 'incompatible with different shape' + ).toBe(false); + expect(space.isCompatible([1, 1, undefined]), 'compatible with coordinates of same shape').toBe( + true + ); + expect( + space.isCompatible([1, 1, [1, 2, 3]]), + 'incompatible with coordinates of different shape' + ).toBe(false); +}); + +test('Position', () => { + const space = new MatchingSpace({shape: [1, 1, 3]}); + const coordinates = [1, 1, [1, 2, undefined]]; + const wrongCoords = [1, 1, [1, 2]]; + const position = new Position({coordinates, space}); + expect(position.shape, 'position shape').toEqual([1, 1, 3]); + expect( + () => new Position({coordinates: wrongCoords, space}), + 'mismatching coordinates and space' + ).toThrow(); +}); + +test('createSubspace', () => { + const dimensions = [1, 1, 3]; + const questions = dimensions.map((i) => new MockQuestion(i)); + expect( + createSubspace({questions, subset: questions.slice(1)}).weights, + 'weights outside subset to be zero' + ).toEqual([0, 1, 1]); +}); diff --git a/frontend/src/lib/voter/vaa-matching/tests/utils.ts b/frontend/src/lib/voter/vaa-matching/tests/utils.ts new file mode 100644 index 000000000..c886fc880 --- /dev/null +++ b/frontend/src/lib/voter/vaa-matching/tests/utils.ts @@ -0,0 +1,149 @@ +import { + type AnswerDict, + type HasAnswers, + type MatchableQuestion, + MISSING_VALUE, + type Id +} from 'vaa-shared'; +import {OrdinalQuestion} from '../src/question'; +import {MatchingAlgorithm} from '../src/algorithms'; +import type {DistanceMetric} from '../src/distance'; +import type {Match} from '../src/match'; +import type {MissingValueMethod} from '../src/missingValue'; + +/********************************************************************** + * Helper functions + **********************************************************************/ + +/** + * Create dummy matches for testing. + * @param voterAnswers Array of voter answers as indeces of the likert scale + * @param candidateAnswers Array of Arrays of candidate answers as numbers of the likert scale (1-based) + * @param likertScale The likert scale, e.g. 5 + * @param distanceMetric The DistanceMetric to use + * @param method The MISSING_VALUE_METHOD + * @returns A dict of all generated objects + */ +export function createMatchesAndEntities( + voterAnswers: Array, + candidateAnswers: Array>, + likertScale: number, + distanceMetric: DistanceMetric, + method: MissingValueMethod +): { + questions: Array; + voter: Candidate; + candidates: Array; + algorithm: MatchingAlgorithm; + matches: Array>; +} { + const numQuestions = voterAnswers.length; + // Create dummy questions + const questions = createQuestions(numQuestions, likertScale); + if ([voterAnswers, ...candidateAnswers].some((a) => a.length !== numQuestions)) + throw new Error('All answers must have the same length as the Likert scale.'); + if ( + [voterAnswers, ...candidateAnswers].flat().some((a) => a != null && (a < 1 || a > likertScale)) + ) + throw new Error('All answers must have be within [0, Likert scale).'); + // Match answer ids + function answers(answers: Array): Array { + return answers.map((a) => (a == null ? undefined : questions[0].values[a - 1]?.id)); + } + // Create a Candidate to represent the voter + const voter = createVoter(questions, answers(voterAnswers)); + // Create dummy candidates + const candidates = createCandidates( + questions, + candidateAnswers.map((a) => answers(a)) + ); + // Matching algorithm + const algorithm = new MatchingAlgorithm({ + distanceMetric, + missingValueOptions: {method} + }); + // Get matches + const matches = algorithm.match({questions, reference: voter, targets: candidates}); + return { + questions, + voter, + candidates, + algorithm, + matches + }; +} + +/** + * A dummy candidate object for matching. + */ +export class Candidate implements HasAnswers { + constructor(public answers: AnswerDict) {} +} + +/** + * A mock question for creating spaces. + */ +export class MockQuestion implements MatchableQuestion { + id: Id; + constructor(public normalizedDimensions: number) { + this.id = `mockQuestion_${Math.random()}`; + } + normalizeValue() { + return MISSING_VALUE; + } +} + +/** + * Create dummy answers. + * @param questions Question list + * @param answerValues The answer values + * @returns An answer dict + */ +export function createAnswers( + questions: Array, + answerValues: Array +): AnswerDict { + const answers = {} as AnswerDict; + for (let i = 0; i < questions.length; i++) { + answers[questions[i].id] = {value: answerValues[i]}; + } + return answers; +} + +/** + * Create dummy questions. + * @param numQuestions Number of questions to creata + * @param scale The likert scale, e.g. 5 + * @returns Array of questions + */ +export function createQuestions(numQuestions: number, scale: number): Array { + return Array.from({length: numQuestions}, (_, i) => + OrdinalQuestion.fromLikert({id: `qst${i}`, scale}) + ); +} + +/** + * Create dummy candidates + * @param questions The dummy questions + * @param candidateAnswers Array of Arrays of candidate answers + * @returns Array of candidates + */ +export function createCandidates( + questions: Array, + candidateAnswers: Array> +): Candidate[] { + return candidateAnswers.map((o) => new Candidate(createAnswers(questions, o))); +} + +/** + * Create a dummy Candidate to represent the voter + * @param questions The dummy questions + * @param voterAnswers Array of voter answers + * @returns A candidate + */ +export function createVoter( + questions: Array, + voterAnswers: Array +): Candidate { + return new Candidate(createAnswers(questions, voterAnswers)); +}