From 60d42f60b8144412e85488ee0ae87ebd87eaae39 Mon Sep 17 00:00:00 2001 From: michealroberts Date: Wed, 8 Jan 2025 20:40:25 +0000 Subject: [PATCH] feat: add performLinearRegression to stats module in @observerly/fits feat: add performLinearRegression to stats module in @observerly/fits --- src/stats/__tests__/regression.spec.ts | 118 +++++++++++++++++++++++++ src/stats/index.ts | 1 + src/stats/regression.ts | 61 +++++++++++++ 3 files changed, 180 insertions(+) create mode 100644 src/stats/__tests__/regression.spec.ts create mode 100644 src/stats/regression.ts diff --git a/src/stats/__tests__/regression.spec.ts b/src/stats/__tests__/regression.spec.ts new file mode 100644 index 0000000..6c3f754 --- /dev/null +++ b/src/stats/__tests__/regression.spec.ts @@ -0,0 +1,118 @@ +/*****************************************************************************************************************/ + +// @author Michael Roberts +// @package @observerly/fits +// @license Copyright © 2021-2025 observerly + +/*****************************************************************************************************************/ + +import { describe, expect, it } from 'vitest' + +import { type Point, performLinearRegression } from '../regression' + +/*****************************************************************************************************************/ + +describe('performLinearRegression', () => { + it('should correctly compute slope and intercept for a simple linear relationship', () => { + const points: Point[] = [ + { x: 0, y: 1 }, + { x: 1, y: 3 }, + { x: 2, y: 5 }, + { x: 3, y: 7 } + ] + + const { m, c } = performLinearRegression(points) + + expect(m).toBeCloseTo(2) + expect(c).toBeCloseTo(1) + }) + + it('should handle floating point values accurately', () => { + const points: Point[] = [ + { x: 0.5, y: 2.1 }, + { x: 1.5, y: 3.9 }, + { x: 2.5, y: 5.8 }, + { x: 3.5, y: 7.7 } + ] + + const { m, c } = performLinearRegression(points) + + expect(m).toBeCloseTo(1.867, 2) + expect(c).toBeCloseTo(1.135, 2) + }) + + it('should correctly compute slope and intercept for a vertical line-like data', () => { + const points: Point[] = [ + { x: 1, y: 2 }, + { x: 2, y: 4 }, + { x: 3, y: 6 }, + { x: 4, y: 8 } + ] + + const { m, c } = performLinearRegression(points) + expect(m).toBeCloseTo(2) + expect(c).toBeCloseTo(0) + }) + + it('should handle negative slopes correctly', () => { + const points: Point[] = [ + { x: 0, y: 10 }, + { x: 1, y: 8 }, + { x: 2, y: 6 }, + { x: 3, y: 4 } + ] + + const { m, c } = performLinearRegression(points) + expect(m).toBeCloseTo(-2) + expect(c).toBeCloseTo(10) + }) + + it('should handle points with zero variance in y', () => { + const points: Point[] = [ + { x: 0, y: 5 }, + { x: 1, y: 5 }, + { x: 2, y: 5 }, + { x: 3, y: 5 } + ] + + const { m, c } = performLinearRegression(points) + expect(m).toBeCloseTo(0) + expect(c).toBeCloseTo(5) + }) + + it('should compute correct values for a random set of points', () => { + const points: Point[] = [ + { x: 1, y: 2 }, + { x: 2, y: 3 }, + { x: 3, y: 5 }, + { x: 4, y: 4 }, + { x: 5, y: 6 } + ] + + const { m, c } = performLinearRegression(points) + expect(m).toBeCloseTo(0.9) + expect(c).toBeCloseTo(1.3) + }) + + it('should throw an error when no points are provided', () => { + const points: Point[] = [] + + expect(() => performLinearRegression(points)).toThrow( + 'No valid points provided for linear regression.' + ) + }) + + it('should throw an error when all x values are the same', () => { + const points: Point[] = [ + { x: 2, y: 3 }, + { x: 2, y: 4 }, + { x: 2, y: 5 } + ] + + expect(() => performLinearRegression(points)).toThrow( + 'Denominator is zero. Cannot compute linear regression.' + ) + }) +}) + +/*****************************************************************************************************************/ diff --git a/src/stats/index.ts b/src/stats/index.ts index 3b2805b..c699a7b 100644 --- a/src/stats/index.ts +++ b/src/stats/index.ts @@ -8,6 +8,7 @@ export { mean } from './mean' export { median } from './median' +export { performLinearRegression, type Point } from './regression' export { variance } from './variance' /*****************************************************************************************************************/ diff --git a/src/stats/regression.ts b/src/stats/regression.ts new file mode 100644 index 0000000..9b886b3 --- /dev/null +++ b/src/stats/regression.ts @@ -0,0 +1,61 @@ +/*****************************************************************************************************************/ + +// @author Michael Roberts +// @package @observerly/fits +// @license Copyright © 2021-2025 observerly + +/*****************************************************************************************************************/ + +export type Point = { + x: number + y: number +} + +/*****************************************************************************************************************/ + +/** + * + * performLinearRegression + * + * Calculates the linear regression (slope and intercept) for a set of points. + * This is crucial for identifying the trend within the pixel data, enabling effective contrast adjustment. + * + * @param points: Array of points containing x and y coordinates. + * @returns: An object containing the y-intercept (`c`) and the slope (`m`) of the fitted line. + * @throws Will throw an error if there is insufficient variation in the x-values. + */ +export function performLinearRegression(points: Point[]): { m: number; c: number } { + const n = points.length + + if (n === 0) { + throw new Error('No valid points provided for linear regression.') + } + + // Aggregate sums required for calculating slope and intercept using reduce for immutability and clarity: + const { sumX, sumY, sumXY, sumX2 } = points.reduce( + (acc, { x, y }) => ({ + sumX: acc.sumX + x, + sumY: acc.sumY + y, + sumXY: acc.sumXY + x * y, + sumX2: acc.sumX2 + x ** 2 + }), + { sumX: 0, sumY: 0, sumXY: 0, sumX2: 0 } + ) + + // Calculate the denominator to ensure there is enough variation in x-values for a valid regression: + const denominator = n * sumX2 - sumX ** 2 + + if (denominator === 0) { + throw new Error('Denominator is zero. Cannot compute linear regression.') + } + + // Compute the slope (m) of the best-fit line: + const m = (n * sumXY - sumX * sumY) / denominator + + // Compute the y-intercept (c) of the best-fit line: + const c = (sumY - m * sumX) / n + + return { m, c } +} + +/*****************************************************************************************************************/