statistics/statistics_OLS.js

//Initialise functions
{
	if (!global.Statistics)
		/**
		 * The namespace for all UF/Statistics utility functions, typically for static methods.
		 *
		 * @namespace Statistics
		 */
		global.Statistics = {};
	
	/**
	 * Computes the X^T * X matrix manually to save memory.
	 * @alias Statistics._computeXT_X
	 *
	 * @param {Array<Array<number>>} arg0_X
	 *
	 * @returns {Array<Array<number>>}
	 */
	Statistics._computeXT_X = function (arg0_X) {
		let X = arg0_X;
		let N = X.length;
		let K = X[0].length;
		
		let XT_X = Array.createMatrix(K, K);
		
		//Optimisation: Halved loop multiplications taking advantage of reflectional symmetry
		for (let i = 0; i < N; i++) {
			let rowX = X[i];
			for (let j = 0; j < K; j++) {
				let x_j = rowX[j];
				for (let k = j; k < K; k++) {
					XT_X[j][k] += x_j * rowX[k];
				}
			}	
		}
		
		//Mirror the computed lower half logic to the upper half logic
		for (let j = 0; j < K; j++) {
			for (let k = 0; k < j; k++) {
				XT_X[j][k] = XT_X[k][j];
			}
		}
		
		return XT_X;
	};
	
	/**
	 * Computes the VIF of a given matrix.
	 * @alias Statistics.computeVIF
	 *
	 * @param {Matrix|Array} arg0_X
	 *
	 * @returns {Array<number>}
	 */
	Statistics.computeVIF = function (arg0_X) {
		//Convert from parameters
		let X = arg0_X;
		try { X = X._data || X; } catch (e) {}
		
		//Declare local instance variables
		let XT_X = Statistics._computeXT_X(X);
		let XT_X_inv = mathjs.inv(XT_X);
		try { XT_X_inv = XT_X_inv._data || XT_X_inv; } catch(e) {}
		
		let vif = XT_X_inv.map((row, i) => row[i]);
		
		//Return statement
		return vif;
	};
	
	/**
	 * Returns the condition number of a given matrix.
	 * @alias Statistics.conditionNumber
	 *
	 * @param {Matrix|Array} arg0_X
	 * @param {number} [arg1_epsilon=1e-12]
	 *
	 * @returns {number}
	 */
	Statistics.conditionNumber = function (arg0_X, arg1_epsilon) {
		//Convert from parameters
		let X = arg0_X;
		let epsilon = Math.returnSafeNumber(arg1_epsilon, 1e-12);
		try { X = X._data || X; } catch (e) {}
		
		//Declare local instance variables
		let XT_X = Statistics._computeXT_X(X);
		
		let matrix = new ml_matrix.SVD(XT_X, { autoTranspose: true });
		let singular_values = matrix.diagonal.map(v => Math.sqrt(Math.max(0, v)));
		
		//Find max and min singular values
		let max_s = Math.max(...singular_values);
		let min_s = Math.max(Math.min(...singular_values), epsilon); //Ensure min_s is never 0
		
		//Return statement
		return max_s/min_s;
	};
	
	/**
	 * Geometrically average OLS models across a folder path, with base OLS prefixes.
	 * @alias Statistics.geomeanOLSModels
	 * 
	 * @param {string} arg0_input_folder_path
	 * @param {string} arg1_ols_prefix
	 * @param {Object} [arg2_options]
	 *  @param {function} [arg2_options.weighting_function] - (arg0_coefficient:{@link number}) | {@link number}
	 *  
	 * @returns {Promise<{coefficients: {}, raw_coefficients: {}}>}
	 */
	Statistics.geomeanOLSModels = async function (arg0_input_folder_path, arg1_ols_prefix, arg2_options) { 
		let input_folder_path = arg0_input_folder_path;
		let ols_prefix = arg1_ols_prefix;
		let options = (arg2_options) ? arg2_options : {};
		
		//Declare local instance variables
		let all_coefficients = {};
		let raw_coefficients = {};
		
		//Iterate over all_input_files matching ols_prefix
		let all_input_files = await File.getAllFiles(input_folder_path);
		
		for (let i = 0; i < all_input_files.length; i++)
			if (all_input_files[i].endsWith(".json")) {
				let local_split_path = all_input_files[i].split(/[/\\]/);
				let local_file_name = local_split_path[local_split_path.length - 1];
				
				if (local_file_name.startsWith(ols_prefix)) {
					let rawdata = JSON.parse(fs.readFileSync(all_input_files[i], "utf8"));
					let { coefficients } = rawdata;
					
					//Aggregate coefficients for geometric mean calculation
					for (let key in coefficients) {
						if (!all_coefficients[key]) all_coefficients[key] = [];
						if (!raw_coefficients[key]) raw_coefficients[key] = [];
						all_coefficients[key].push(coefficients[key]);
						raw_coefficients[key].push(coefficients[key]);
					}
				}
			}
		
		//Iterate over all_coefficients; compute geometric mean for each coefficient
		let format_slug = ols_prefix.split("_").join(" ").trim().split(" ").join("_");
		let hybrid_coefficients = {};
		
		for (let key in all_coefficients) {
			hybrid_coefficients[key] = Math.weightedGeometricMean(all_coefficients[key]);
			if (options.weighting_function)
				hybrid_coefficients[key] = options.weighting_function(hybrid_coefficients[key]);
		}
		
		let output_data = {
			coefficients: hybrid_coefficients,
			raw_coefficients
		};
		let output_path = `${input_folder_path}/geomean_${format_slug}.json`;
		
		fs.writeFileSync(output_path, JSON.stringify(output_data, null, 2));
		console.log(`OLS weighted geometric mean calculated and saved to ${output_path}.`);
		
		//Return statement
		return output_data;
	};
	
	/**
	 * Generates an OLS raster from a stack of coefficients.
	 * @alias Statistics.generateOLSRaster
	 * 
	 * @param {string} arg0_output_file_path
	 * @param {Object} [arg1_options]
	 *  @param {Object} arg1_options.covariates_obj
	 *  @param {string} [arg1_options.format="int32"]
	 *  @param {Array} [arg1_options.formatting_parameters]
	 *  @param {function} [arg1_options.guard_clause] - (local_index:{@link number}, rasters_obj:{@link Object}) - `false` skips pixel processing.
	 *  @param {Object|string} [arg1_options.model_obj] - File path or JSON object.
	 *  @param {string} [arg1_options.utility_format="int32"]
	 *  
	 *  @param {number} [arg1_options.height=2160]
	 *  @param {number} [arg1_options.width=4320]
	 * 
	 * @returns {Promise<void>}
	 */
	Statistics.generateOLSRaster = async function (arg0_output_file_path, arg1_options) {
		//Convert from parameters
		let output_file_path = arg0_output_file_path;
		let options = (arg1_options) ? arg1_options : {};
		
		//Return statement
		return await Statistics.LearningFramework.predictRaster(output_file_path, options.model_obj, {
			...options,
			mode: "ols"
		});
	};
	
	/**
	 * Loads a stack of covariates for a specific utility file path for OLS training.
	 * @alias Statistics.loadOLSCovariates
	 *
	 * @param {string} arg0_utility_file_path
	 * @param {Object} [arg1_options]
	 *  @param {Object} arg1_options.covariates_obj
	 *  @param {any[]} [arg1_options.formatting_parameters]
	 *  @param {string} [arg1_options.utility_format="int32"]
	 *  
	 * @returns {Promise<Object>}
	 */
	Statistics.loadOLSCovariates = async function (arg0_utility_file_path, arg1_options) {
		//Convert from parameters
		let utility_file_path = arg0_utility_file_path;
		let options = (arg1_options) ? arg1_options : {};
		
		//Return statement
		return await Statistics.LearningFramework.extractImageDataset(utility_file_path, {
			...options,
			mode: "ols"
		});
	};
	
	/**
	 * Loads a stack of covariates for point-based data for OLS training for a specific year.
	 * @alias Statistics.loadPointOLSCovariates
	 *
	 * @param {Array<Object>} arg0_points - Array of objects with { coords: [lng, lat], target: number, year: number }
	 * @param {number} arg1_year - The target year to load covariates and sample points for.
	 * @param {Object} [arg2_options]
	 *  @param {Object} arg2_options.covariates_obj
	 *  @param {number} [arg2_options.covariates_year] - Mapped year to override path resolution.
	 *  @param {Function} [arg2_options.get_pixel_function] - Custom coordinate-to-pixel mapping function.
	 *
	 * @returns {Promise<Object>}
	 */
	Statistics.loadPointOLSCovariates = async function (arg0_points, arg1_year, arg2_options) {
		//Convert from parameters
		let points_list = arg0_points;
		let target_year = arg1_year;
		let options = (arg2_options) ? arg2_options : {};
		
		//Return statement
		return await Statistics.LearningFramework.extractPointDataset(points_list, target_year, options);
	};
	
	/**
	 * Processes and adjusts OLS model coefficients against target and covariate rasters using Multiplicative Update Rules (NMF).
	 * @alias Statistics.processOLSModel
	 *
	 * @param {string|Object} arg0_model - JSON model object or file path to JSON model.
	 * @param {Object} [arg1_options]
	 *  @param {Object} [arg1_options.covariates_obj] - Map of covariate keys to functions or file paths.
	 *  @param {boolean} [arg1_options.debug=false]
	 *  @param {string} [arg1_options.output_file_path] - Path to save the adjusted model JSON.
	 *  @param {Array<any>|any} [arg1_options.steps] - Array of steps or parameter sets (e.g., years) passed into target and covariate functions.
	 *  @param {string|Function} [arg1_options.target] - File path or function returning file path for target raster.
	 *  @param {string} [arg1_options.target_format="float32"]
	 *
	 * @returns {Promise<Object>}
	 */
	Statistics.processOLSModel = async function (arg0_model, arg1_options) {
		//Convert from parameters
		let processed_model = File.loadJSON(arg0_model);
		let options = (arg1_options) ? arg1_options : {};
		
		//Initialise options
		let covariates_obj = (options.covariates_obj) ? options.covariates_obj : {};
		let target_entry = (options.target) ? options.target : options.target_raster;
		let steps = (options.steps) ? (Array.isArray(options.steps) ? options.steps : [options.steps]) : [[]];
		
		//Ensure all coefficients are positive
		let all_coefficients = Object.keys(processed_model.coefficients);
		
		for (let i = 0; i < all_coefficients.length; i++)
			processed_model.coefficients[all_coefficients[i]] = Math.abs(processed_model.coefficients[all_coefficients[i]]);
		
		//Iterate over formatting parameters steps
		for (let i = 0; i < steps.length; i++) try {
			let current_params = Array.isArray(steps[i]) ? steps[i] : [steps[i]];
			console.log(`Processing OLS model adjustment for step [${current_params.join(", ")}] ..`);
			
			let local_target_file_path = (typeof target_entry === "function") ? target_entry(...current_params) : target_entry;
			let local_target_format = options.target_format || "float32";
			
			if (Array.isArray(local_target_file_path)) {
				local_target_format = local_target_file_path[1];
				local_target_file_path = local_target_file_path[0];
			}
			
			let local_covariate_images = {};
			let valid_covariate_keys = [];
			
			for (let key in covariates_obj) {
				let covariate_entry = covariates_obj[key];
				let local_file_path = (typeof covariate_entry === "function") ? covariate_entry(...current_params) : covariate_entry;
				let local_format = "float32";
				
				if (Array.isArray(local_file_path)) {
					local_format = local_file_path[1];
					local_file_path = local_file_path[0];
				}
				
				try {
					local_covariate_images[key] = GeoPNG.loadNumberRasterImage(local_file_path, {
						format: local_format
					});
					valid_covariate_keys.push(key);
				} catch (e) {
					console.warn(`- [WARN] Missing covariate raster for ${key} at ${local_file_path}. Filtering out key.`);
				}
			}
			
			let local_target_image = GeoPNG.loadNumberRasterImage(local_target_file_path, {
				format: local_target_format
			});
			
			//Guard clause if no valid_covariate_keys present
			if (valid_covariate_keys.length === 0) {
				console.warn(`- [WARN] No covariate keys for step [${current_params.join(", ")}]! Skipping iteration.`);
				continue;
			}
			
			//Initialise accumulators for Multiplicative Update Rule
			let numerators = {};
			let denominators = {};
			for (let k = 0; k < valid_covariate_keys.length; k++) {
				numerators[valid_covariate_keys[k]] = 0;
				denominators[valid_covariate_keys[k]] = 0;
			}
			
			//Iterate over all pixels
			console.log(`- Aggregating global gradients (Multiplicative Update) ..`);
			let pixel_count = local_target_image.width * local_target_image.height;
			
			for (let x = 0; x < pixel_count; x++) {
				let observed_value = local_target_image.data[x] || 0;
				if (isNaN(observed_value)) observed_value = 0;
				
				//Compute total predicted_value for this pixel
				let predicted_value = 0;
				for (let k = 0; k < valid_covariate_keys.length; k++) {
					let key = valid_covariate_keys[k];
					let covariate_value = local_covariate_images[key].data[x] || 0;
					if (isNaN(covariate_value)) covariate_value = 0;
					
					let coefficient = processed_model.coefficients[key] || 0;
					predicted_value += covariate_value * coefficient;
				}
				
				//If there is data interaction in this pixel, accumulate global sums
				if (predicted_value > 0 || observed_value > 0) {
					for (let k = 0; k < valid_covariate_keys.length; k++) {
						let key = valid_covariate_keys[k];
						let covariate_value = local_covariate_images[key].data[x] || 0;
						
						if (isNaN(covariate_value) || covariate_value === 0) continue;
						
						numerators[key] += observed_value * covariate_value;
						denominators[key] += predicted_value * covariate_value;
					}
				}
			}
			
			//Apply exact multiplicative updates
			console.log(`- Applying global scaling multipliers ..`);
			for (let k = 0; k < valid_covariate_keys.length; k++) {
				let key = valid_covariate_keys[k];
				
				if (denominators[key] > 0) {
					// Mathematically optimal ratio for this step
					let multiplier = numerators[key] / denominators[key];
					processed_model.coefficients[key] *= multiplier;
					
					if (options.debug) {
						console.log(`  - ${key}: Numerator: ${numerators[key].toExponential(2)}, Denominator: ${denominators[key].toExponential(2)} -> Multiplier: ${multiplier.toFixed(5)}`);
					}
				} else if (denominators[key] === 0 && numerators[key] > 0) {
					// Edge case: Model predicted 0, but target exists. 
					// (Very rare if initial OLS coefficients > 0)
					if (options.debug) console.log(`  - ${key}: Missed prediction. Numerator > 0 but Denominator is 0.`);
				}
			}
			
			await Blacktraffic.yield();
			console.log(`- New coefficients:`, processed_model.coefficients);
		} catch (e) {
			console.error(`Statistics.processOLSModel(): Error when processing step:`);
			console.error(e);
		}
		
		//Save adjusted coefficients if output_file_path is provided
		if (options.output_file_path) {
			let output_file_path = path.resolve(options.output_file_path);
			fs.writeFileSync(output_file_path, JSON.stringify(processed_model, null, 2));
			console.log(`Processed model data saved successfully in ${output_file_path}.`);
		}
		
		//Return statement
		return processed_model;
	};
	
	/**
	 * Removes high VIF features for a given matrix.
	 * @alias Statistics.removeHighVIFFeatures
	 *
	 * @param {Matrix|Array} arg0_X
	 * @param {number} [arg1_threshold=10]
	 *
	 * @returns {Array<Array<number>>}
	 */
	Statistics.removeHighVIFFeatures = function (arg0_X, arg1_threshold) {
		//Convert from parameters
		let X = arg0_X;
		let threshold = Math.returnSafeNumber(arg1_threshold, 10);
		try { X = X._data || X; } catch(e) {}
		
		//Declare local instance variables
		let vif_scores = Statistics.computeVIF(X);
		let to_keep = vif_scores.map((vif, i) => (vif < threshold));
		
		//Return statement
		return X.map((row) => row.filter((_, index) => to_keep[index]));
	};
	
	/**
	 * Performs Ridge Regression on two matrices with RMS scale stabilization and pseudo-inverse fallbacks.
	 * @alias Statistics.ridgeRegression
	 *
	 * @param {Matrix|Array} arg0_X
	 * @param {Matrix|Array} arg1_Y
	 * @param {number} [arg2_lambda=1e-3]
	 *
	 * @returns {Matrix|Array}
	 */
	Statistics.ridgeRegression = function (arg0_X, arg1_Y, arg2_lambda) {
		//Convert from parameters
		let X = arg0_X;
		let Y = arg1_Y;
		let lambda = Math.returnSafeNumber(arg2_lambda, 1e-3);
		
		try { X = X._data || X; } catch (e) {}
		try { Y = Y._data || Y; } catch (e) {}
		
		//Declare local instance variables
		let N = X.length;
		if (N === 0) return [];
		
		let K = X[0].length;
		if (K === 0) return [];
		
		//Compute column-wise RMS scales to prevent scale-mismatch singularity
		let scales = new Array(K).fill(1);
		let X_scaled = Array.createMatrix(N, K);
		
		for (let j = 0; j < K; j++) {
			let sum_sq = 0;
			for (let i = 0; i < N; i++) {
				sum_sq += X[i][j] * X[i][j];
			}
			let rms = Math.sqrt(sum_sq / N);
			scales[j] = (rms > 1e-12) ? rms : 1;
		}
		
		//Scale covariates
		for (let i = 0; i < N; i++) {
			for (let j = 0; j < K; j++) {
				X_scaled[i][j] = X[i][j] / scales[j];
			}
		}
		
		let XT_X = Array.createMatrix(K, K);
		let XT_Y = Array.createMatrix(K, 1);
		
		for (let i = 0; i < N; i++) {
			let row_X = X_scaled[i];
			let y_val = Y[i][0];
			for (let j = 0; j < K; j++) {
				let x_j = row_X[j];
				XT_Y[j][0] += x_j * y_val;
				for (let k = j; k < K; k++) {
					XT_X[j][k] += x_j * row_X[k];
				}
			}
		}
		
		for (let j = 0; j < K; j++) {
			for (let k = 0; k < j; k++) {
				XT_X[j][k] = XT_X[k][j];
			}
		}
		
		let XT_X_mat = mathjs.matrix(XT_X);
		let XT_Y_mat = mathjs.matrix(XT_Y);
		let identity = mathjs.identity(K);
		let XT_X_reg = mathjs.add(XT_X_mat, mathjs.multiply(identity, lambda)); //Ridge term
		
		//Attempt standard inverse; fall back to Moore-Penrose pseudo-inverse (pinv) if determinant is zero
		let beta_scaled;
		try {
			beta_scaled = mathjs.multiply(mathjs.inv(XT_X_reg), XT_Y_mat);
		} catch (e) {
			console.log(`- Determinant is zero or matrix is near-singular. Falling back to Moore-Penrose pseudo-inverse.`);
			beta_scaled = mathjs.multiply(mathjs.pinv(XT_X_reg), XT_Y_mat);
		}
		
		//Convert scaled coefficients back to original covariate scale: beta_j = beta_scaled_j / scale_j
		let beta_scaled_arr = Array.unwrapMatrix(beta_scaled);
		let beta_orig = Array.createMatrix(K, 1);
		
		for (let j = 0; j < K; j++) {
			beta_orig[j][0] = beta_scaled_arr[j][0] / scales[j];
		}
		
		//Return statement; return beta matrix
		return mathjs.matrix(beta_orig);
	};
	
	/**
	 * Trains a raster-based OLS model given a fitted covariates object { X, Y, keys }.
	 * @alias Statistics.trainOLSModel
	 *
	 * @param {string} arg0_output_file_path
	 * @param {Object} arg1_covariates_obj
	 * @param {Object} [arg2_options]
	 *  @param {boolean} [arg2_options.dynamic_lambda=false] - Condition numbers are dynamically selected if true.
	 *  @param {number} [arg2_options.lambda=1e9]
	 *  @param {boolean} [arg2_options.remove_high_vif_features=false] - Whether to remove high VIF features.
	 *  @param {string} [arg2_options.key]
	 *  @param {function} [arg2_options.weighting_function] - (arg0_coefficient:{@link number}) | {@link number}
	 *
	 * @returns {Promise<Object>}
	 */
	Statistics.trainOLSModel = async function (arg0_output_file_path, arg1_covariates_obj, arg2_options) {
		//Convert from parameters
		let output_file_path = path.resolve(arg0_output_file_path);
		let covariates_obj = arg1_covariates_obj;
		let options = (arg2_options) ? arg2_options : {};
		
		//Initialise options
		if (!options.key) options.key = output_file_path;
		
		//Declare local instance variables
		let basename = path.basename(output_file_path);
		let { keys, X, Y } = covariates_obj;
		
		console.log(`- Performing OLS for ${basename}.`);
		
		if (!X || X.length === 0 || !keys || keys.length === 0) {
			console.warn(`- Empty covariate data passed for ${basename}. Skipping.`);
			return null;
		}
		
		//1. Remove multicollinear features using VIF selection if specified
		if (options.remove_high_vif_features) {
			X = Statistics.removeHighVIFFeatures(X, 10);
			console.log(` - Removed high VIF features.`);
		}
		
		//2. Apply RMS scaling to X before Ridge Regression
		let N = X.length;
		let K = keys.length;
		let rms_vals = new Float64Array(K);
		
		for (let j = 0; j < K; j++) {
			let sum_sq = 0;
			for (let i = 0; i < N; i++) sum_sq += X[i][j]*X[i][j];
			let rms = Math.sqrt(sum_sq / N);
			rms_vals[j] = (rms > 1e-12) ? rms : 1;
		}
		
		let X_scaled = Array.createMatrix(N, K);
		for (let i = 0; i < N; i++) {
			for (let j = 0; j < K; j++) {
				X_scaled[i][j] = X[i][j] / rms_vals[j];
			}
		}
		
		let selected_lambda = Math.returnSafeNumber(options.lambda, 1e9);
		console.log(`- Computed preliminary matrices.`);
		
		if (options.dynamic_lambda) {
			let condition_number = Statistics.conditionNumber(X_scaled);
			condition_number *= 1e3;
			console.log(`- Condition Number: ${condition_number}, using Lambda = ${selected_lambda}`);
		}
		
		let beta = Statistics.ridgeRegression(X_scaled, Y, selected_lambda);
		
		if (!beta || beta.length === 0) {
			console.warn(`- Regression failed to produce beta coefficients for ${basename}.`);
			return null;
		}
		
		console.log(`- Applied Ridge Regression to stabilise coefficients.`);
		
		//3. Convert coefficients to JSON, unscale, and clamp to [-1, 1]
		let beta_arr = Array.unwrapMatrix(beta);
		let coefficients = beta_arr.flat();
		console.log(`- Computed coefficients.`);
		
		let coefficients_obj = {};
		for (let j = 0; j < K; j++) {
			let raw_beta = coefficients[j] / rms_vals[j];
			
			//User directive: any coefficients over 1 or under -1 are erroneous in % space
			if (options.clamp_percentage && (raw_beta > 1 || raw_beta < -1)) raw_beta = 0;
			
			if (options.weighting_function)
				raw_beta = options.weighting_function(raw_beta);
				
			coefficients_obj[keys[j]] = raw_beta;
		}
		
		//Save model to JSON
		let model_data_obj = {
			key: options.key,
			coefficients: coefficients_obj
		};
		
		fs.writeFileSync(output_file_path, JSON.stringify(model_data_obj, null, 2));
		console.log(`OLS model data for ${options.key} saved successfully in ${output_file_path}.`);
		
		//Return statement
		return model_data_obj;
	};

	/**
	 * Concurrently trains a series of OLS models across background worker processes.
	 * @alias Statistics.trainOLSModelsParallel
	 *
	 * @param {Array<any>} arg0_items - Array of years, models, or tasks to train.
	 * @param {Function} arg1_task_generator - (local_item: any, local_index: number) => { output_file_path, target_file_path, target_format, covariates_map, options, points, target_year }
	 * @param {Object} [arg2_options]
	 *  @param {number} [arg2_options.concurrency] - Worker thread count.
	 *  @param {string} [arg2_options.name="OLS Model Training"]
	 *
	 * @returns {Promise<Array<Object>>}
	 */
	Statistics.trainOLSModelsParallel = async function (arg0_items, arg1_task_generator, arg2_options) {
		//Convert from parameters
		let items = (arg0_items) ? arg0_items : [];
		let task_generator = arg1_task_generator;
		let options = (arg2_options) ? arg2_options : {};
		
		//Declare local instance variables
		let name = options.name || "OLS Model Training";
		
		if (items.length === 0) return [];
		
		//Return statement
		return await GeoPNG.processTimeseriesParallel({
			concurrency: options.concurrency,
			items: items,
			name: name,
			task_generator: (item, index) => {
				let task_def = task_generator(item, index);
				if (!task_def) return null;
				if (task_def.type) return task_def;
				
				return {
					type: (task_def.points) ? "train_ols_points" : "train_ols",
					covariates_map: task_def.covariates_map,
					covariates_year: task_def.covariates_year,
					options: task_def.options || {},
					output_file_path: task_def.output_file_path,
					points: task_def.points,
					target_file_path: task_def.target_file_path,
					target_format: task_def.target_format || "float32",
					target_year: task_def.target_year || item
				};
			},
			handler: async (item, index) => {
				let task_def = task_generator(item, index);
				if (!task_def) return null;
				
				if (task_def.points) {
					let dataset = await Statistics.LearningFramework.extractPointDataset(task_def.points, task_def.target_year || item, {
						covariates_obj: task_def.covariates_map,
						covariates_year: task_def.covariates_year || item
					});
					if (!dataset || !dataset.X || dataset.X.length === 0) return null;
					return Statistics.trainOLSModel(task_def.output_file_path, dataset, task_def.options || {});
				} else {
					let loaded_obj = await Statistics.loadOLSCovariates(task_def.target_file_path, {
						covariates_obj: task_def.covariates_map,
						formatting_parameters: (task_def.options && task_def.options.formatting_parameters) ? task_def.options.formatting_parameters : [],
						utility_format: task_def.target_format || "float32",
						weight_file_path: (task_def.options && task_def.options.weight_file_path) ? task_def.options.weight_file_path : undefined
					});
					if (task_def.options && task_def.options.filter_zero_targets && loaded_obj && loaded_obj.Y) {
						let filtered_X = [];
						let filtered_Y = [];
						for (let j = 0; j < loaded_obj.Y.length; j++) {
							let utility_val = loaded_obj.Y[j][0];
							if (utility_val !== 0 && !isNaN(utility_val)) {
								filtered_X.push(loaded_obj.X[j]);
								filtered_Y.push(loaded_obj.Y[j]);
							}
						}
						loaded_obj.X = filtered_X;
						loaded_obj.Y = filtered_Y;
					}
					if (!loaded_obj || !loaded_obj.X || loaded_obj.X.length === 0) return null;
					return Statistics.trainOLSModel(task_def.output_file_path, loaded_obj, task_def.options || {});
				}
			}
		});
	};

	/**
	 * Concurrently generates a series of OLS predicted rasters across background worker processes.
	 * @alias Statistics.generateOLSRastersParallel
	 *
	 * @param {Array<any>} arg0_items - Array of years, models, or tasks to generate.
	 * @param {Function} arg1_task_generator - (local_item: any, local_index: number) => { output_file_path, model_obj, covariates_map, options }
	 * @param {Object} [arg2_options]
	 *  @param {number} [arg2_options.concurrency]
	 *  @param {string} [arg2_options.name="OLS Raster Generation"]
	 *
	 * @returns {Promise<Array<Object>>}
	 */
	Statistics.generateOLSRastersParallel = async function (arg0_items, arg1_task_generator, arg2_options) {
		//Convert from parameters
		let items = (arg0_items) ? arg0_items : [];
		let task_generator = arg1_task_generator;
		let options = (arg2_options) ? arg2_options : {};
		
		//Declare local instance variables
		let name = options.name || "OLS Raster Generation";
		
		if (items.length === 0) return [];
		
		//Return statement
		return await GeoPNG.processTimeseriesParallel({
			concurrency: options.concurrency,
			items: items,
			name: name,
			task_generator: (item, index) => {
				let task_def = task_generator(item, index);
				if (!task_def) return null;
				if (task_def.type) return task_def;
				
				return {
					type: "generate_ols_raster",
					covariates_map: task_def.covariates_map,
					model_obj: task_def.model_obj,
					options: task_def.options || {},
					output_file_path: task_def.output_file_path
				};
			},
			handler: async (item, index) => {
				let task_def = task_generator(item, index);
				if (!task_def) return null;
				return await Statistics.generateOLSRaster(task_def.output_file_path, {
					...(task_def.options || {}),
					covariates_obj: task_def.covariates_map,
					model_obj: task_def.model_obj
				});
			}
		});
	};
}