Skip to content

Commit

Permalink
fix(web): simplified mean
Browse files Browse the repository at this point in the history
  • Loading branch information
jahorton committed Nov 24, 2023
1 parent b9a584a commit 12aad6c
Show file tree
Hide file tree
Showing 3 changed files with 204 additions and 36 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,40 @@ export class CumulativePathStats<Type = any> {
return this._initialSample;
}

/**
* In order to mitigate the accumulation of small floating-point errors during the
* various accumulations performed by this class, the domain of incoming values
* is remapped near to the origin via axis-specific mapping constants.
* @param dim
* @returns
*/
protected mappingConstant(dim: StatAxis) {
if(!this.baseSample) {
return undefined;
}

if(dim == 't') {
return this.baseSample.t;
} else if(dim == 'x') {
return this.baseSample.targetX;
} else if(dim == 'y') {
return this.baseSample.targetY;
} else {
return 0;
}
}

/**
* Gets the statistical mean, utilizing the internal 'mapped' coordinate space.
* This is the version compatible with cross-sums and squared-sums.
* @param dim
* @returns
*/
protected mappedMean(dim: StatAxis) {
// super.mean() is basically this; we map it here, though.
return this.rawLinearSums[dim] / this.sampleCount;
}

/**
* Gets the statistical mean value of the samples observed during the represented
* interval on the specified axis.
Expand All @@ -238,7 +272,7 @@ export class CumulativePathStats<Type = any> {
public mean(dim: StatAxis) {
// This external-facing version needs to provide values in 'external'-friendly
// coordinate space.
return this.rawLinearSums[dim] / this.sampleCount;
return this.mappedMean(dim) + this.mappingConstant(dim);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -317,40 +317,6 @@ export class RegressiblePathStats<Type = any> extends CumulativePathStats<Type>
return result;
}

/**
* In order to mitigate the accumulation of small floating-point errors during the
* various accumulations performed by this class, the domain of incoming values
* is remapped near to the origin via axis-specific mapping constants.
* @param dim
* @returns
*/
protected mappingConstant(dim: StatAxis) {
if(!this.baseSample) {
return undefined;
}

if(dim == 't') {
return this.baseSample.t;
} else if(dim == 'x') {
return this.baseSample.targetX;
} else if(dim == 'y') {
return this.baseSample.targetY;
} else {
return 0;
}
}

/**
* Gets the statistical mean, utilizing the internal 'mapped' coordinate space.
* This is the version compatible with cross-sums and squared-sums.
* @param dim
* @returns
*/
protected mappedMean(dim: StatAxis) {
// super.mean() is basically this; we map it here, though.
return this.rawLinearSums[dim] / this.sampleCount;
}

/**
* Gets the statistical mean value of the samples observed during the represented
* interval on the specified axis.
Expand Down
170 changes: 169 additions & 1 deletion common/web/gesture-recognizer/src/test/auto/headless/pathStats.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,176 @@
import { assert } from 'chai';
import { RegressiblePathStats, InputSample } from '@keymanapp/gesture-recognizer';
import { CumulativePathStats, RegressiblePathStats, InputSample } from '@keymanapp/gesture-recognizer';

import { TouchpathTurtle } from '#tools';

describe("CumulativePathStats", function() {
it("Sample count = 0", function() {
let stats = new CumulativePathStats();

assert.equal(stats.duration, 0);
assert.equal(stats.angle, undefined);
assert.equal(stats.cardinalDirection, undefined);
assert.equal(stats.rawDistance, 0);
assert.equal(stats.netDistance, 0);
assert.isNaN(stats.mean('x'));
assert.isNaN(stats.mean('y'));
});

it("Sample count = 1", function() {
let stats = new CumulativePathStats();
stats = stats.extend({
targetX: 4,
targetY: 8,
t: 40
});

assert.equal(stats.duration, 0);
assert.equal(stats.angle, undefined);
assert.equal(stats.cardinalDirection, undefined);
assert.equal(stats.mean('x'), 4);
assert.equal(stats.mean('y'), 8);
});

it("Basic accumulation (perfect correlation)", function() {
const samples: InputSample<any>[] = [];

// Exactly 5 points, evenly spaced and linear.
// So, the arithmetic mean should be very obvious - it's the middle sample.
for(let i = 0; i < 5; i++) {
samples.push({
targetX: 4 * i + 10,
targetY: 4 * i + 20,
t: 100 * i + 10000
});
}

let stats = new CumulativePathStats();

for(const sample of samples) {
stats = stats.extend(sample);
}

assert.equal(stats.mean('x'), samples[2].targetX);
assert.equal(stats.mean('y'), samples[2].targetY);
assert.equal(stats.mean('t'), samples[2].t);

assert.equal(stats.angle, 135 * Math.PI / 180);
assert.equal(stats.rawDistance, 16 * Math.SQRT2); // 4 intervals of length 4 * sqrt(2)
assert.equal(stats.cardinalDirection, 'se');
});

it("Immutability (aside from .followingSample)", function() {
const samples: InputSample<any>[] = [];

// Exactly 5 points, evenly spaced and linear.
// So, the arithmetic mean should be very obvious - it's the middle sample.
for(let i = 0; i < 5; i++) {
samples.push({
targetX: 4 * i + 10,
targetY: 4 * i + 20,
t: 100 * i + 10000
});
}

let stats = new CumulativePathStats();
let preStats: CumulativePathStats[] = [];
let postStats: CumulativePathStats[] = [];

for(const sample of samples) {
const initialValue = stats;
// The constructor provides a deep-copy mechanism.
preStats.push(new CumulativePathStats(initialValue));
stats = stats.extend(sample);
postStats.push(new CumulativePathStats(initialValue));
}

// The one not-immutable part: `.followingSample`. It's needed for some of the
// internal mechanisms - for `.deaccumulate`, in particular.
for(let obj of postStats) {
// is technically private; we delete it b/c it'd get in the way of the assertion below.
delete obj['followingSample'];
}

// The very first sample has a few more changes because of recording the first (and thus, base) sample.
// So we ignore it.
preStats.splice(0, 1);
postStats.splice(0, 1);

assert.sameDeepOrderedMembers(postStats, preStats);
});

it("Deaccumulation", function() {
const sampleSet1: InputSample<any>[] = [];

// Exactly 5 points, evenly spaced and linear.
// So, the arithmetic mean should be very obvious - it's the middle sample.
for(let i = 0; i < 5; i++) {
sampleSet1.push({
targetX: 4 * i + 10, // Final: 26
targetY: 4 * i + 20, // Final: 36
t: 100 * i + 10000 // Final: 10400
});
}

let firstHalfStats = new CumulativePathStats();

for(const sample of sampleSet1) {
firstHalfStats = firstHalfStats.extend(sample);
}

const splitPoint = {
targetX: 30,
targetY: 40,
t: 10500
};

firstHalfStats = firstHalfStats.extend(splitPoint);

const sampleSet2: InputSample<any>[] = [];

// Exactly 5 points, evenly spaced and linear.
// So, the arithmetic mean should be very obvious - it's the middle sample.
for(let i = 0; i < 5; i++) {
sampleSet2.push({
targetX: 4 * i + 34, // unchanged direction
targetY: -4 * i + 36, // flipped to the opposite direction
t: 100 * i + 10600
});
}

let secondHalfStats = new CumulativePathStats();
let combinedStats = firstHalfStats;

for(const sample of sampleSet2) {
secondHalfStats = secondHalfStats.extend(sample);
combinedStats = combinedStats.extend(sample);
}

// Reconstructs the second half by 'deaccumulating' the first half from the full accumulation.
// (Not including the split-point.)
// This is pretty core to our segmentation algorithm's efficiency.
let deaccumulatedSecondHalfStats = combinedStats.deaccumulate(firstHalfStats);

// Base sample will differ because `secondHalfStats` was started independently.
// This means that the linear, cross, and squaredSums WILL NOT BE EQUAL.
// But, the statistical properties? THOSE should match.

assert.equal(deaccumulatedSecondHalfStats.mean('x'), secondHalfStats.mean('x'));
assert.equal(deaccumulatedSecondHalfStats.mean('y'), secondHalfStats.mean('y'));
assert.equal(deaccumulatedSecondHalfStats.mean('t'), secondHalfStats.mean('t'));

// Floating-point "equality".
assert.closeTo(deaccumulatedSecondHalfStats.netDistance, secondHalfStats.netDistance, 1e-8);
assert.closeTo(deaccumulatedSecondHalfStats.rawDistance, secondHalfStats.rawDistance, 1e-8);

assert.equal(deaccumulatedSecondHalfStats.duration, secondHalfStats.duration);
assert.equal(deaccumulatedSecondHalfStats.angle, secondHalfStats.angle);

assert.equal(deaccumulatedSecondHalfStats.initialSample, secondHalfStats.initialSample);
assert.equal(deaccumulatedSecondHalfStats.lastSample, secondHalfStats.lastSample);
});
});

describe("RegressiblePathStats", function() {
it("Sample count = 0", function() {
let stats = new RegressiblePathStats();
Expand Down

0 comments on commit 12aad6c

Please sign in to comment.