statistics/statistics_logit.js

//Initialise functions
{
	if (!global.Statistics)
		/**
		 * The namespace for all UF/Statistics utility functions, typically for static methods.
		 *
		 * @namespace Statistics
		 */
		global.Statistics = {};
	
	/**
	 * Returns the class label with the highest probability.
	 * @alias Statistics.argmaxMultinomialClass
	 *
	 * @param {Object} arg0_probabilities - Map of class labels to probabilities.
	 * @param {Array} arg1_classes
	 *
	 * @returns {any}
	 */
	Statistics.argmaxMultinomialClass = function (arg0_probabilities, arg1_classes) {
		//Convert from parameters
		let probabilities = arg0_probabilities;
		let classes = arg1_classes;
		
		//Declare local instance variables
		let best_class = classes[0];
		let best_probability = -1;
		
		//Argmax over class probabilities
		for (let c = 0; c < classes.length; c++) {
			let local_probability = probabilities[String(classes[c])] || 0;
			
			if (local_probability > best_probability) {
				best_probability = local_probability;
				best_class = classes[c];
			}
		}
		
		//Return statement
		return best_class;
	};
	
	/**
	 * Computes logits for a single sample against a CxK coefficient matrix. Row 0 of beta
	 * is assumed to be the zeroed reference class.
	 * @alias Statistics.computeMultinomialLogits
	 *
	 * @param {Array<number>} arg0_row_X
	 * @param {Array<Array<number>>} arg1_beta
	 *
	 * @returns {Array<number>}
	 */
	Statistics.computeMultinomialLogits = function (arg0_row_X, arg1_beta) {
		//Convert from parameters
		let row_X = arg0_row_X;
		let beta = arg1_beta;
		
		//Declare local instance variables
		let C = beta.length;
		let K = row_X.length;
		let logits = new Array(C).fill(0);
		
		//Iterate over all non-reference classes; accumulate dot products
		for (let c = 1; c < C; c++)
			for (let j = 0; j < K; j++)
				logits[c] += row_X[j]*beta[c][j];
		
		//Return statement
		return logits;
	};
	
	/**
	 * Computes approximate per-class standard errors from the Hessian at convergence.
	 * Uses the standard class-vs-rest approximation H_c = Σ p_ic(1-p_ic) x_i x_i^T + λI.
	 * @alias Statistics.computeMultinomialStandardErrors
	 *
	 * @param {Array<Array<number>>} arg0_X
	 * @param {Array<Array<number>>} arg1_Y
	 * @param {Object} arg2_result - Result object from Statistics.multinomialLogitRegression.
	 * @param {number} [arg3_lambda=1e-3]
	 *
	 * @returns {Object} - Map of class labels to arrays of standard errors.
	 */
	Statistics.computeMultinomialStandardErrors = function (arg0_X, arg1_Y, arg2_result, arg3_lambda) {
		//Convert from parameters
		let X = arg0_X;
		let Y = arg1_Y;
		let result = arg2_result;
		let lambda = Math.returnSafeNumber(arg3_lambda, 1e-3);
		
		//Declare local instance variables
		let N = X.length;
		let K = X[0].length;
		let classes = result.classes;
		let beta = result.beta;
		let C = classes.length;
		let standard_errors = {};
		
		//Iterate over all non-reference classes
		for (let c = 1; c < C; c++) {
			let hessian = Array.createMatrix(K, K);
			
			//Accumulate Hessian; halved loop taking advantage of reflectional symmetry
			for (let i = 0; i < N; i++) {
				let row_X = X[i];
				let probabilities = Statistics.softmax(Statistics.computeMultinomialLogits(row_X, beta));
				let weight = probabilities[c]*(1 - probabilities[c]);
				if (weight < 1e-12) continue;
				
				for (let j = 0; j < K; j++)
					for (let k = j; k < K; k++)
						hessian[j][k] += weight*row_X[j]*row_X[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++)
					hessian[j][k] = hessian[k][j];
			
			//Add ridge term to diagonal for invertibility
			for (let j = 0; j < K; j++)
				hessian[j][j] += lambda;
			
			//Attempt standard inverse; fall back to Moore-Penrose pseudo-inverse
			let hessian_inv;
			try {
				hessian_inv = mathjs.inv(hessian);
			} catch (e) {
				hessian_inv = mathjs.pinv(hessian);
			}
			hessian_inv = Array.unwrapMatrix(hessian_inv);
			
			standard_errors[String(classes[c])] = hessian_inv.map((row, j) =>
				Math.sqrt(Math.max(0, row[j]))
			);
		}
		
		//Return statement
		return standard_errors;
	};
	
	/**
	 * Evaluates a trained multinomial logit model against a covariates object, returning
	 * overall accuracy and a confusion matrix.
	 * @alias Statistics.evaluateMultinomialModel
	 *
	 * @param {string|Object} arg0_model - JSON model object or file path to JSON model.
	 * @param {Object} arg1_covariates_obj - { keys, X, Y }
	 *
	 * @returns {Object} - { accuracy, confusion_matrix, sample_count }
	 */
	Statistics.evaluateMultinomialModel = function (arg0_model, arg1_covariates_obj) {
		//Convert from parameters
		let model_obj = File.loadJSON(arg0_model);
		let { keys, X, Y } = arg1_covariates_obj;
		
		//Declare local instance variables
		let classes = model_obj.classes;
		let confusion_matrix = {};
		let correct_count = 0;
		
		//Iterate over all samples
		for (let i = 0; i < X.length; i++) {
			let local_features = Object.fromArrays(keys, X[i]);
			let probabilities = Statistics.predictMultinomialProbabilities(local_features, model_obj);
			let actual_class = String(Statistics.getClassLabel(Y, i));
			let best_class = String(Statistics.argmaxMultinomialClass(probabilities, classes));
			
			if (!confusion_matrix[actual_class]) confusion_matrix[actual_class] = {};
			confusion_matrix[actual_class][best_class] =
				(confusion_matrix[actual_class][best_class] || 0) + 1;
			
			if (best_class === actual_class) correct_count++;
		}
		
		//Return statement
		return {
			accuracy: (X.length > 0) ? correct_count/X.length : 0,
			confusion_matrix: confusion_matrix,
			sample_count: X.length
		};
	};
	
	/**
	 * Generates raster predictions from a trained multinomial logit model. Supports argmax
	 * class maps, single-class probability surfaces, or one probability raster per class.
	 * @alias Statistics.generateMultinomialRaster
	 *
	 * @param {string} arg0_output_file_path
	 * @param {Object} [arg1_options]
	 *  @param {number|string} [arg1_options.class] - Class label for "probability" output_mode.
	 *  @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.output_mode="class"] - "class" | "probability" | "probabilities".
	 *
	 *  @param {number} [arg1_options.height=2160]
	 *  @param {number} [arg1_options.width=4320]
	 *
	 * @returns {Promise<void>}
	 */
	Statistics.generateMultinomialRaster = 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 : {};
		
		//Declare local instance variables
		let model_obj = (typeof options.model_obj === "string") ? File.loadJSON(options.model_obj) : options.model_obj;
		let mode = options.mode || ((model_obj && model_obj.type === "anchored_multinomial_gam") ? "anchored_multinomial_gam" : "multinomial_logit");

		//Return statement
		return await Statistics.LearningFramework.predictRaster(output_file_path, options.model_obj, {
			...options,
			mode: mode
		});
	};
	
	/**
	 * Loads a stack of covariates for a categorical class raster for multinomial logit training.
	 * Unlike loadOLSCovariates, zero-valued class labels are meaningful and retained.
	 * @alias Statistics.loadMultinomialCovariates
	 *
	 * @param {string} arg0_class_file_path - Raster whose pixel values are integer class labels.
	 * @param {Object} [arg1_options]
	 *  @param {Object} arg1_options.covariates_obj
	 *  @param {string} [arg1_options.class_format="int32"]
	 *  @param {any[]} [arg1_options.formatting_parameters]
	 *  @param {number} [arg1_options.nodata_value] - Class label treated as nodata and dropped.
	 *
	 * @returns {Promise<Object>} - { keys, X, Y }
	 */
	Statistics.loadMultinomialCovariates = async function (arg0_class_file_path, arg1_options) {
		//Convert from parameters
		let class_file_path = arg0_class_file_path;
		let options = (arg1_options) ? arg1_options : {};
		
		//Return statement
		return await Statistics.LearningFramework.extractImageDataset(class_file_path, {
			...options,
			mode: "multinomial_logit"
		});
	};
	
	/**
	 * Loads a stack of covariates for point-based categorical data (e.g. household survey
	 * age/sex buckets) for multinomial logit training for a specific year.
	 * @alias Statistics.loadPointMultinomialCovariates
	 *
	 * @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>} - { keys, X, Y }
	 */
	Statistics.loadPointMultinomialCovariates = 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);
	};
	
	/**
	 * Performs L2-regularised Multinomial Logistic Regression (softmax regression) via batch
	 * gradient descent with momentum. The first observed class is the reference class with
	 * coefficients fixed at zero for identifiability.
	 * @alias Statistics.multinomialLogitRegression
	 *
	 * @param {Matrix|Array} arg0_X
	 * @param {Matrix|Array} arg1_Y - Nx1 matrix of integer class labels.
	 * @param {Object} [arg2_options]
	 *  @param {boolean} [arg2_options.debug=false]
	 *  @param {number} [arg2_options.lambda=1e-3] - L2 regularisation strength (ridge analogue).
	 *  @param {number} [arg2_options.learning_rate=0.5]
	 *  @param {number} [arg2_options.max_iterations=1000]
	 *  @param {number} [arg2_options.momentum=0.9]
	 *  @param {Array<number>} [arg2_options.sample_weights] - Non-negative observation weights.
	 *  @param {number} [arg2_options.tolerance=1e-8]
	 *
	 * @returns {Object|null} - { beta, classes, converged, iterations, log_likelihood }
	 */
	Statistics.multinomialLogitRegression = function (arg0_X, arg1_Y, arg2_options) {
		//Convert from parameters
		let X = Array.unwrapMatrix(arg0_X);
		let Y = Array.unwrapMatrix(arg1_Y);
		let options = (arg2_options) ? arg2_options : {};
		
		//Initialise options
		let lambda = Math.returnSafeNumber(options.lambda, 1e-3);
		let learning_rate = Math.returnSafeNumber(options.learning_rate, 0.5);
		let max_iterations = Math.returnSafeNumber(options.max_iterations, 100);
		let momentum = Math.returnSafeNumber(options.momentum, 0.9);
		let tolerance = Math.returnSafeNumber(options.tolerance, 1e-5);
		
		//Declare local instance variables
		let N = X.length;
		if (N === 0) return null;
		let sample_weights = new Float64Array(N);
		let total_weight = 0;

		for (let i = 0; i < N; i++) {
			let local_weight = (options.sample_weights && options.sample_weights[i] !== undefined) ?
				Math.returnSafeNumber(options.sample_weights[i], 0) : 1;
			sample_weights[i] = Math.max(0, local_weight);
			total_weight += sample_weights[i];
		}
		if (total_weight <= 0) return null;
		
		let K_in = X[0].length;
		if (K_in === 0) return null;
		
		let is_proportions = (options.proportions || (Array.isArray(Y[0]) && Y[0].length > 1));
		let use_intercept = (options.fit_intercept !== false);
		let K = use_intercept ? K_in + 1 : K_in;
		
		//Build class lookup; index 0 is the reference/baseline class
		let classes = [];
		let class_lookup = {};
		
		if (options.classes && options.classes.length > 0) {
			classes = [...options.classes];
			if (options.reference_class && classes.includes(options.reference_class)) {
				let ref_idx = classes.indexOf(options.reference_class);
				if (ref_idx > 0) {
					classes.splice(ref_idx, 1);
					classes.unshift(options.reference_class);
				}
			}
			for (let c = 0; c < classes.length; c++)
				class_lookup[classes[c]] = c;
		} else if (is_proportions) {
			classes = (Y[0].map) ? Y[0].map((_, idx) => idx) : [];
			for (let c = 0; c < classes.length; c++)
				class_lookup[classes[c]] = c;
		} else {
			for (let i = 0; i < N; i++) {
				let local_class = Statistics.getClassLabel(Y, i);
				
				if (class_lookup[local_class] === undefined) {
					class_lookup[local_class] = classes.length;
					classes.push(local_class);
				}
			}
		}
		
		let C = classes.length;
		if (C < 2) {
			console.warn(`- Multinomial logit requires at least 2 classes, received ${C}.`);
			return null;
		}
		
		//Compute column-wise RMS scales to prevent scale-mismatch instability
		let scales = new Array(K_in).fill(1);
		let X_scaled = Array.createMatrix(N, K);
		
		for (let j = 0; j < K_in; j++) {
			let sum_sq = 0;
			for (let i = 0; i < N; i++)
				sum_sq += sample_weights[i]*X[i][j]*X[i][j];
			
			let rms = Math.sqrt(sum_sq/total_weight);
			scales[j] = (rms > 1e-12) ? rms : 1;
		}
		
		//Scale covariates with optional prepended intercept
		for (let i = 0; i < N; i++) {
			if (use_intercept) {
				X_scaled[i][0] = 1.0;
				for (let j = 0; j < K_in; j++)
					X_scaled[i][j + 1] = X[i][j]/scales[j];
			} else {
				for (let j = 0; j < K_in; j++)
					X_scaled[i][j] = X[i][j]/scales[j];
			}
		}
		
		//Initialise coefficients (CxK) and velocity; row 0 stays zeroed (reference class)
		let beta = Array.createMatrix(C, K);
		let velocity = Array.createMatrix(C, K);
		let converged = false;
		let iterations_run = 0;
		let log_likelihood = 0;
		
		//Main gradient descent loop
		for (let iter = 0; iter < max_iterations; iter++) {
			let gradient = Array.createMatrix(C, K);
			log_likelihood = 0;
			
			//Accumulate gradients over all samples
			for (let i = 0; i < N; i++) {
				let sample_weight = sample_weights[i];
				if (sample_weight <= 0) continue;
				let row_X = X_scaled[i];
				let probabilities = Statistics.softmax(Statistics.computeMultinomialLogits(row_X, beta));
				
				if (is_proportions) {
					let prop_row = Y[i];
					for (let c = 0; c < C; c++) {
						let q_c = prop_row[c] || 0;
						if (q_c > 0) log_likelihood += sample_weight*q_c*Math.log(Math.max(probabilities[c], 1e-15));
					}
					for (let c = 1; c < C; c++) {
						let residual = sample_weight*(probabilities[c] - (prop_row[c] || 0));
						for (let j = 0; j < K; j++)
							gradient[c][j] += residual*row_X[j];
					}
				} else {
					let y_idx = class_lookup[Statistics.getClassLabel(Y, i)];
					log_likelihood += sample_weight*Math.log(Math.max(probabilities[y_idx], 1e-15));
					
					//Gradient of NLL w.r.t. beta_c is (p_c - 1[y=c]) * x
					for (let c = 1; c < C; c++) {
						let residual = sample_weight*(probabilities[c] - ((c === y_idx) ? 1 : 0));
						for (let j = 0; j < K; j++)
							gradient[c][j] += residual*row_X[j];
					}
				}
			}
			
			//Apply L2 penalty (unpenalised intercept) and momentum update
			let max_update = 0;
			
			for (let c = 1; c < C; c++)
				for (let j = 0; j < K; j++) {
					let penalty = (use_intercept && j === 0) ? 0 : lambda*beta[c][j];
					let local_gradient = gradient[c][j]/total_weight + penalty;
					
					velocity[c][j] = momentum*velocity[c][j] - learning_rate*local_gradient;
					beta[c][j] += velocity[c][j];
					max_update = Math.max(max_update, Math.abs(velocity[c][j]));
				}
			
			iterations_run = iter + 1;
			
			if (options.debug && (iter % 10 === 0 || iter === max_iterations - 1))
				console.log(`- Iteration ${iter}/${max_iterations}: NLL = ${(-log_likelihood/total_weight).toFixed(6)}, max_update = ${max_update.toExponential(2)}`);
			
			if (max_update < tolerance) {
				converged = true;
				break;
			}
		}
		
		//Convert scaled coefficients back to original covariate scale
		let beta_orig = Array.createMatrix(C, K);
		
		for (let c = 1; c < C; c++) {
			if (use_intercept) {
				beta_orig[c][0] = beta[c][0];
				for (let j = 0; j < K_in; j++)
					beta_orig[c][j + 1] = beta[c][j + 1]/scales[j];
			} else {
				for (let j = 0; j < K_in; j++)
					beta_orig[c][j] = beta[c][j]/scales[j];
			}
		}
		
		//Return statement
		return {
			beta: beta_orig,
			classes: classes,
			converged: converged,
			has_intercept: use_intercept,
			iterations: iterations_run,
			log_likelihood: log_likelihood,
			weight_sum: total_weight
		};
	};
	
	/**
	 * Predicts class probabilities from an ensemble of models using a convex combination in probability space.
	 * @alias Statistics.predictMultinomialEnsemble
	 *
	 * @param {Object} arg0_features_obj - Map of covariate keys to feature values.
	 * @param {Array<Object|string>} arg1_models - Array of model objects or file paths.
	 * @param {Array<number>} [arg2_weights] - Optional array of model weights.
	 *
	 * @returns {Object} - Map of class labels to blended probabilities.
	 */
	Statistics.predictMultinomialEnsemble = function (arg0_features_obj, arg1_models, arg2_weights) {
		//Convert from parameters
		let features_obj = arg0_features_obj;
		let models = (arg1_models) ? arg1_models : [];
		let weights = (arg2_weights) ? arg2_weights : [];
		
		//Guard clauses
		if (models.length === 0) return {};
		
		//Declare local instance variables
		let first_model = (typeof models[0] === "string") ? File.loadJSON(models[0]) : models[0];
		let classes = (first_model && first_model.classes) ? first_model.classes : [];
		let return_obj = {};
		let total_weight = 0;
		let valid_weights = [];
		
		for (let c = 0; c < classes.length; c++)
			return_obj[String(classes[c])] = 0;
		
		for (let m = 0; m < models.length; m++) {
			let w = (weights[m] !== undefined) ? Math.returnSafeNumber(weights[m], 1) : 1;
			valid_weights.push(w);
			total_weight += w;
		}
		
		for (let m = 0; m < models.length; m++) {
			let sub_model = (typeof models[m] === "string") ? File.loadJSON(models[m]) : models[m];
			let norm_w = (total_weight > 0) ? (valid_weights[m]/total_weight) : (1/models.length);
			let sub_probs = Statistics.predictMultinomialProbabilities(features_obj, sub_model);
			
			for (let c = 0; c < classes.length; c++) {
				let class_key = String(classes[c]);
				return_obj[class_key] += norm_w*Math.returnSafeNumber(sub_probs[class_key], 0);
			}
		}
		
		//Return statement
		return return_obj;
	};
	
	/**
	 * Predicts class probabilities for a single feature object against a trained multinomial logit model.
	 * Supports both discrete multinomial_logit models and probability-space multinomial_ensemble mixtures.
	 * @alias Statistics.predictMultinomialProbabilities
	 *
	 * @param {Object} arg0_features_obj - Map of covariate keys to feature values.
	 * @param {Object} arg1_model_obj - Trained model object with .classes and .coefficients.
	 *
	 * @returns {Object} - Map of class labels to probabilities.
	 */
	Statistics.predictMultinomialProbabilities = function (arg0_features_obj, arg1_model_obj) {
		//Convert from parameters
		let features_obj = arg0_features_obj;
		let model_obj = arg1_model_obj;
		
		//Guard clauses
		if (!model_obj) return {};
		
		if (model_obj.type === "multinomial_ensemble" && Array.isArray(model_obj.models)) {
			let models_arr = model_obj.models.map((m) => m.model);
			let weights_arr = model_obj.models.map((m) => m.weight);
			return Statistics.predictMultinomialEnsemble(features_obj, models_arr, weights_arr);
		}
		
		//Declare local instance variables
		let classes = model_obj.classes;
		let logits = new Array(classes.length).fill(0);
		
		//Iterate over all classes; missing coefficient blocks default to a logit of 0 (reference class)
		for (let c = 0; c < classes.length; c++) {
			let local_coefficients = model_obj.coefficients[String(classes[c])];
			if (!local_coefficients) continue;
			
			Object.iterate(local_coefficients, (local_key, local_value) => {
				if (local_key === "_intercept") {
					logits[c] += local_value;
				} else {
					logits[c] += Math.returnSafeNumber(features_obj[local_key])*local_value;
				}
			});
		}
		
		let probabilities = Statistics.softmax(logits);
		let return_obj = {};
		
		for (let c = 0; c < classes.length; c++)
			return_obj[String(classes[c])] = probabilities[c];
		
		//Return statement
		return return_obj;
	};
	
	/**
	 * Computes a numerically-stable softmax over an array of logits.
	 * @alias Statistics.softmax
	 *
	 * @param {Array<number>} arg0_logits
	 *
	 * @returns {Array<number>}
	 */
	Statistics.softmax = function (arg0_logits) {
		//Convert from parameters
		let logits = arg0_logits;
		
		//Declare local instance variables
		let max_logit = Math.max(...logits);
		let sum = 0;
		let exps = new Array(logits.length);
		
		for (let i = 0; i < logits.length; i++) {
			exps[i] = Math.exp(logits[i] - max_logit);
			sum += exps[i];
		}
		
		//Return statement
		return exps.map((local_exp) => local_exp/sum);
	};
	
	/**
	 * Trains a multinomial logit model given a fitted covariates object { X, Y, keys } and
	 * saves it to JSON. Coefficients are stored per non-reference class.
	 * @alias Statistics.trainMultinomialLogitModel
	 *
	 * @param {string} arg0_output_file_path
	 * @param {Object} arg1_covariates_obj - { keys, X, Y }
	 * @param {Object} [arg2_options]
	 *  @param {boolean} [arg2_options.compute_standard_errors=false] - Hessian-based uncertainty.
	 *  @param {boolean} [arg2_options.debug=false]
	 *  @param {string} [arg2_options.key]
	 *  @param {number} [arg2_options.lambda=1e-3]
	 *  @param {number} [arg2_options.learning_rate=0.5]
	 *  @param {number} [arg2_options.max_iterations=1000]
	 *  @param {number} [arg2_options.momentum=0.9]
	 *  @param {number} [arg2_options.tolerance=1e-8]
	 *
	 * @returns {Promise<Object|null>}
	 */
	Statistics.trainMultinomialLogitModel = 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, weights } = covariates_obj;
		
		console.log(`- Performing multinomial logit for ${basename}.`);
		
		if (!X || X.length === 0 || !keys || keys.length === 0) {
			console.warn(`- Empty covariate data passed for ${basename}. Skipping.`);
			return null;
		}
		
		//1. Apply regularised multinomial logit regression
		let regression_options = { ...options };
		if (!regression_options.sample_weights && weights)
			regression_options.sample_weights = weights;
		let result = Statistics.multinomialLogitRegression(X, Y, regression_options);
		
		if (!result) {
			console.warn(`- Regression failed to produce coefficients for ${basename}.`);
			return null;
		}
		
		console.log(`- Multinomial logit ${(result.converged) ? "converged" : "halted"} after ${result.iterations} iterations.`);
		
		//2. Convert coefficients to JSON, keyed by class label then covariate key
		let coefficients_obj = {};
		
		for (let c = 1; c < result.classes.length; c++) {
			coefficients_obj[String(result.classes[c])] = {};
			
			if (result.has_intercept) {
				coefficients_obj[String(result.classes[c])]._intercept = result.beta[c][0];
				for (let j = 0; j < keys.length; j++)
					coefficients_obj[String(result.classes[c])][keys[j]] = result.beta[c][j + 1];
			} else {
				for (let j = 0; j < keys.length; j++)
					coefficients_obj[String(result.classes[c])][keys[j]] = result.beta[c][j];
			}
		}
		
		//3. Compute Hessian-based standard errors if specified
		let standard_errors_obj;
		
		if (options.compute_standard_errors) {
			console.log(`- Computing per-class standard errors from Hessian ..`);
			let standard_errors = Statistics.computeMultinomialStandardErrors(
				X, Y, result, Math.returnSafeNumber(options.lambda, 1e-3)
			);
			
			standard_errors_obj = {};
			Object.iterate(standard_errors, (local_key, local_value) => {
				standard_errors_obj[local_key] = {};
				for (let j = 0; j < keys.length; j++)
					standard_errors_obj[local_key][keys[j]] = local_value[j];
			});
		}
		
		//Save model to JSON
		let model_data_obj = {
			key: options.key,
			type: "multinomial_logit",
			classes: result.classes,
			reference_class: result.classes[0],
			covariates: keys,
			has_intercept: (result.has_intercept === true),
			coefficients: coefficients_obj,
			training: {
				converged: result.converged,
				iterations: result.iterations,
				lambda: Math.returnSafeNumber(options.lambda, 1e-3),
				log_likelihood: result.log_likelihood,
				sample_count: X.length,
				weight_sum: result.weight_sum
			}
		};
		if (standard_errors_obj) model_data_obj.standard_errors = standard_errors_obj;
		
		fs.writeFileSync(output_file_path, JSON.stringify(model_data_obj, null, 2));
		console.log(`Multinomial logit model data for ${options.key} saved successfully in ${output_file_path}.`);
		
		//Return statement
		return model_data_obj;
	};

	/**
	 * Concurrently trains a series of multinomial logit models across background worker processes.
	 * @alias Statistics.trainMultinomialLogitModelsParallel
	 *
	 * @param {Array<any>} arg0_items - Array of tasks, years, or cohort descriptors.
	 * @param {Function} arg1_task_generator - (local_item: any, local_index: number) => { categories, covariates_map, filter_format, filter_raster_path, model_path, options, target_paths }
	 * @param {Object} [arg2_options]
	 *  @param {number} [arg2_options.concurrency] - Worker thread count.
	 *  @param {string} [arg2_options.name="Multinomial Logit Training"]
	 *
	 * @returns {Promise<Array<Object>>}
	 */
	Statistics.trainMultinomialLogitModelsParallel = 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 || "Multinomial Logit 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: "train_multinomial_logit",
					categories: task_def.categories,
					covariates_map: task_def.covariates_map,
					filter_format: task_def.filter_format || "float32",
					filter_raster_path: task_def.filter_raster_path,
					model_path: task_def.model_path,
					options: task_def.options || {},
					target_paths: task_def.target_paths
				};
			},
			handler: async (item, index) => {
				let task_def = task_generator(item, index);
				if (!task_def) return null;
				
				let categories = task_def.categories || [];
				let covariates_map = task_def.covariates_map || {};
				let model_path = path.resolve(task_def.model_path);
				let opt = task_def.options || {};
				let target_paths = task_def.target_paths || {};
				
				let cov_rasters = {};
				let valid_keys = [];
				for (let k in covariates_map) {
					let entry = covariates_map[k];
					let fmt = Array.isArray(entry) ? entry[1] : "float32";
					let p = Array.isArray(entry) ? entry[0] : entry;
					if (fs.existsSync(p)) {
						cov_rasters[k] = GeoPNG.loadNumberRasterImage(p, { format: fmt });
						valid_keys.push(k);
					}
				}
				
				let missing_target = false;
				let target_rasters = {};
				for (let i = 0; i < categories.length; i++) {
					let cat = categories[i];
					let p = target_paths[cat];
					if (!p || !fs.existsSync(p)) {
						missing_target = true;
						break;
					}
					target_rasters[cat] = GeoPNG.loadNumberRasterImage(p, { format: "float32" });
				}
				
				if (missing_target || valid_keys.length === 0) return null;
				
				let filter_raster = null;
				if (task_def.filter_raster_path && fs.existsSync(task_def.filter_raster_path))
					filter_raster = GeoPNG.loadNumberRasterImage(task_def.filter_raster_path, { format: task_def.filter_format || "float32" });
				
				let first_target = target_rasters[categories[0]];
				let data_len = first_target.data.length;
				let sample_limit = opt.sample_limit || opt.max_samples || 75000;
				let X = [];
				let Y = [];
				let weights = [];
				
				// Identify candidate valid populated indices (requiring filter >= 1.0)
				let candidate_indices = [];
				for (let i = 0; i < data_len; i++) {
					if (filter_raster && filter_raster.data[i] < 1.0) continue;
					candidate_indices.push(i);
				}
				
				let selected_indices = candidate_indices;
				if (candidate_indices.length > sample_limit) {
					selected_indices = [];
					let step = candidate_indices.length / sample_limit;
					for (let s = 0; s < sample_limit; s++)
						selected_indices.push(candidate_indices[Math.floor(s * step)]);
				}
				
				for (let s = 0; s < selected_indices.length; s++) {
					let i = selected_indices[s];
					
					let cat_pops = [];
					let total_pop = 0;
					for (let j = 0; j < categories.length; j++) {
						let cp = target_rasters[categories[j]].data[i];
						cp = (isNaN(cp) || cp < 0) ? 0 : cp;
						cat_pops.push(cp);
						total_pop += cp;
					}
					if (total_pop < 1.0) continue;
					
					let is_valid = true;
					let x_row = [];
					for (let j = 0; j < valid_keys.length; j++) {
						let val = cov_rasters[valid_keys[j]].data[i];
						if (isNaN(val)) {
							is_valid = false;
							break;
						}
						x_row.push(val);
					}
					if (!is_valid) continue;
					
					let prop_row = new Float32Array(categories.length);
					let inv_total = 1/total_pop;
					for (let j = 0; j < categories.length; j++)
						prop_row[j] = cat_pops[j]*inv_total;
					
					X.push(x_row);
					Y.push(prop_row);
					weights.push(Math.log(1 + total_pop));
				}
				
				if (X.length === 0) return null;
				
				return await Statistics.trainMultinomialLogitModel(model_path, { keys: valid_keys, X: X, Y: Y, weights: weights }, {
					...opt,
					classes: categories,
					proportions: true,
					reference_class: categories[0]
				});
			}
		});
	};

	/**
	 * Concurrently generates a series of Multinomial Logit predicted rasters across background worker processes.
	 * @alias Statistics.generateMultinomialRastersParallel
	 *
	 * @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="Multinomial Logit Raster Generation"]
	 *
	 * @returns {Promise<Array<Object>>}
	 */
	Statistics.generateMultinomialRastersParallel = 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 || "Multinomial Logit 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_multinomial_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.generateMultinomialRaster(task_def.output_file_path, {
					...(task_def.options || {}),
					covariates_obj: task_def.covariates_map,
					model_obj: task_def.model_obj
				});
			}
		});
	};
}