node-pandas
Version:
An npm package that incorporates minimal features of python pandas.
562 lines (502 loc) • 16.9 kB
JavaScript
/**
* @fileoverview GroupBy class for the node-pandas library.
* Provides grouping and aggregation functionality for DataFrames.
* Allows grouping data by one or more columns and computing aggregate statistics
* for each group.
*
* Validates: Requirements 5.1, 5.2, 5.3, 5.4, 5.5, 5.6
*/
const Series = require('../series/series');
const { ValidationError, ColumnError } = require('../utils/errors');
const validation = require('../utils/validation');
const typeDetection = require('../utils/typeDetection');
/**
* GroupBy class - Groups DataFrame rows by one or more columns.
*
* Provides aggregation methods (mean, sum, count, min, max, std) that compute
* statistics for each group. Supports both single-column and multi-column grouping
* with hierarchical group organization.
*
* @class GroupBy
*
* @example
* // Single-column grouping
* const df = DataFrame(
* [[1, 'Rishikesh Agrawani', 32, 'Engineering'],
* [2, 'Hemkesh Agrawani', 30, 'Sales'],
* [3, 'Malinikesh Agrawani', 28, 'Engineering']],
* ['id', 'name', 'age', 'department']
* );
*
* const grouped = df.groupBy('department');
* const meanAge = grouped.mean(); // Returns DataFrame with mean age by department
*
* @example
* // Multi-column grouping
* const grouped = df.groupBy(['department', 'name']);
* const counts = grouped.count(); // Returns DataFrame with counts by department and name
*/
class GroupBy {
/**
* Creates a new GroupBy instance.
*
* @param {DataFrame} dataframe - The DataFrame to group
* @param {string|Array<string>} groupingColumns - Column name(s) to group by
*
* @throws {ValidationError} If groupingColumns is not a string or array
* @throws {ColumnError} If any grouping column doesn't exist in the DataFrame
*
* @example
* const grouped = new GroupBy(df, 'department');
* const grouped2 = new GroupBy(df, ['department', 'name']);
*/
constructor(dataframe, groupingColumns) {
// Validate inputs
if (!dataframe || typeof dataframe !== 'object') {
throw new ValidationError('dataframe must be a valid DataFrame', {
operation: 'GroupBy creation',
value: dataframe
});
}
// Normalize groupingColumns to array
let columns = groupingColumns;
if (typeof groupingColumns === 'string') {
columns = [groupingColumns];
} else if (Array.isArray(groupingColumns)) {
columns = groupingColumns;
} else {
throw new ValidationError('groupingColumns must be a string or array of strings', {
operation: 'GroupBy creation',
value: groupingColumns
});
}
// Validate that all grouping columns exist
try {
validation.validateColumnsExist(columns, dataframe.columns);
} catch (error) {
throw new ColumnError(error.message, {
operation: 'GroupBy creation',
column: columns.find(col => !dataframe.columns.includes(col)),
value: dataframe.columns
});
}
this.dataframe = dataframe;
this.groupingColumns = columns;
this.groups = this._createGroups();
}
/**
* Creates internal group structure from the DataFrame.
*
* For single-column grouping, creates a map where keys are group values
* and values are arrays of row indices.
*
* For multi-column grouping, creates a hierarchical structure where each
* level represents a grouping column.
*
* @returns {Map|Object} The group structure
* @private
*/
_createGroups() {
if (this.groupingColumns.length === 1) {
return this._createSingleColumnGroups();
} else {
return this._createMultiColumnGroups();
}
}
/**
* Creates groups for single-column grouping.
*
* @returns {Map} Map where keys are group values and values are row indices
* @private
*/
_createSingleColumnGroups() {
const groups = new Map();
const groupColumn = this.groupingColumns[0];
const columnIndex = this.dataframe.columns.indexOf(groupColumn);
for (let i = 0; i < this.dataframe.rows; i++) {
const row = this.dataframe.data[i];
const groupKey = Array.isArray(row) ? row[columnIndex] : row[groupColumn];
const keyStr = String(groupKey);
if (!groups.has(keyStr)) {
groups.set(keyStr, []);
}
groups.get(keyStr).push(i);
}
return groups;
}
/**
* Creates hierarchical groups for multi-column grouping.
*
* @returns {Object} Hierarchical object structure representing groups
* @private
*/
_createMultiColumnGroups() {
const groups = {};
for (let i = 0; i < this.dataframe.rows; i++) {
const row = this.dataframe.data[i];
let current = groups;
// Navigate/create hierarchy for each grouping column
for (let j = 0; j < this.groupingColumns.length; j++) {
const colName = this.groupingColumns[j];
const columnIndex = this.dataframe.columns.indexOf(colName);
const value = Array.isArray(row) ? row[columnIndex] : row[colName];
const keyStr = String(value);
if (j === this.groupingColumns.length - 1) {
// Last column - store row indices
if (!current[keyStr]) {
current[keyStr] = [];
}
current[keyStr].push(i);
} else {
// Intermediate column - create nested object
if (!current[keyStr]) {
current[keyStr] = {};
}
current = current[keyStr];
}
}
}
return groups;
}
/**
* Extracts all row indices from hierarchical group structure.
*
* @param {Object} obj - The group object (may be nested)
* @returns {Array<number>} Array of row indices
* @private
*/
_extractRowIndices(obj) {
if (Array.isArray(obj)) {
return obj;
}
const indices = [];
for (const key in obj) {
indices.push(...this._extractRowIndices(obj[key]));
}
return indices;
}
/**
* Computes the mean for each group.
*
* Returns a DataFrame where each row represents a group with its grouping
* column values and the mean of numeric columns for that group.
* Non-numeric values are excluded from the calculation.
*
* @returns {DataFrame} DataFrame with group keys and mean values
*
* @example
* const grouped = df.groupBy('department');
* const result = grouped.mean();
* // Returns DataFrame with columns: ['department', 'id', 'age']
* // Each row contains the group key and mean values for numeric columns
*/
mean() {
return this._aggregateNumeric('mean');
}
/**
* Computes the sum for each group.
*
* Returns a DataFrame where each row represents a group with its grouping
* column values and the sum of numeric columns for that group.
* Non-numeric values are excluded from the calculation.
*
* @returns {DataFrame} DataFrame with group keys and sum values
*
* @example
* const grouped = df.groupBy('department');
* const result = grouped.sum();
* // Returns DataFrame with columns: ['department', 'id', 'age']
* // Each row contains the group key and sum values for numeric columns
*/
sum() {
return this._aggregateNumeric('sum');
}
/**
* Computes the count for each group.
*
* Returns a DataFrame where each row represents a group with its grouping
* column values and the count of rows in that group.
*
* @returns {DataFrame} DataFrame with group keys and counts
*
* @example
* const grouped = df.groupBy('department');
* const result = grouped.count();
* // Returns DataFrame with columns: ['department', 'count']
* // Each row contains the group key and the number of rows in that group
*/
count() {
const results = [];
const groupKeys = this._getGroupKeys();
for (const groupKey of groupKeys) {
const rowIndices = this._getRowIndicesForKey(groupKey);
const resultRow = this._buildResultRow(groupKey, { count: rowIndices.length });
results.push(resultRow);
}
return this._createResultDataFrame(results, { count: true });
}
/**
* Computes the minimum for each group.
*
* Returns a DataFrame where each row represents a group with its grouping
* column values and the minimum of numeric columns for that group.
* Non-numeric values are excluded from the calculation.
*
* @returns {DataFrame} DataFrame with group keys and minimum values
*
* @example
* const grouped = df.groupBy('department');
* const result = grouped.min();
* // Returns DataFrame with columns: ['department', 'id', 'age']
* // Each row contains the group key and minimum values for numeric columns
*/
min() {
return this._aggregateNumeric('min');
}
/**
* Computes the maximum for each group.
*
* Returns a DataFrame where each row represents a group with its grouping
* column values and the maximum of numeric columns for that group.
* Non-numeric values are excluded from the calculation.
*
* @returns {DataFrame} DataFrame with group keys and maximum values
*
* @example
* const grouped = df.groupBy('department');
* const result = grouped.max();
* // Returns DataFrame with columns: ['department', 'id', 'age']
* // Each row contains the group key and maximum values for numeric columns
*/
max() {
return this._aggregateNumeric('max');
}
/**
* Computes the standard deviation for each group.
*
* Returns a DataFrame where each row represents a group with its grouping
* column values and the standard deviation of numeric columns for that group.
* Non-numeric values are excluded from the calculation.
* Uses sample standard deviation (divides by n-1).
*
* @returns {DataFrame} DataFrame with group keys and standard deviation values
*
* @example
* const grouped = df.groupBy('department');
* const result = grouped.std();
* // Returns DataFrame with columns: ['department', 'id', 'age']
* // Each row contains the group key and std values for numeric columns
*/
std() {
return this._aggregateNumeric('std');
}
/**
* Aggregates numeric columns using the specified method.
*
* @param {string} method - The aggregation method ('mean', 'sum', 'min', 'max', 'std')
* @returns {DataFrame} DataFrame with aggregation results
* @private
*/
_aggregateNumeric(method) {
const results = [];
const groupKeys = this._getGroupKeys();
for (const groupKey of groupKeys) {
const rowIndices = this._getRowIndicesForKey(groupKey);
const aggregations = {};
// Aggregate each numeric column
for (let colIndex = 0; colIndex < this.dataframe.columns.length; colIndex++) {
const colName = this.dataframe.columns[colIndex];
// Skip grouping columns
if (this.groupingColumns.includes(colName)) {
continue;
}
// Extract values for this column from the group
const values = rowIndices.map(rowIdx => {
const row = this.dataframe.data[rowIdx];
return Array.isArray(row) ? row[colIndex] : row[colName];
});
// Check if column is numeric
const numericValues = values
.map(v => this._toNumeric(v))
.filter(v => v !== null);
if (numericValues.length > 0) {
aggregations[colName] = this._computeAggregation(method, numericValues);
}
}
const resultRow = this._buildResultRow(groupKey, aggregations);
results.push(resultRow);
}
return this._createResultDataFrame(results, { numeric: true });
}
/**
* Converts a value to numeric if possible.
*
* @param {*} value - The value to convert
* @returns {number|null} The numeric value or null if not convertible
* @private
*/
_toNumeric(value) {
if (value === null || value === undefined) {
return null;
}
if (typeof value === 'number') {
return value;
}
if (typeof value === 'string') {
const num = parseFloat(value);
return isNaN(num) ? null : num;
}
return null;
}
/**
* Computes an aggregation on numeric values.
*
* @param {string} method - The aggregation method
* @param {Array<number>} values - The numeric values to aggregate
* @returns {number} The aggregation result
* @private
*/
_computeAggregation(method, values) {
if (values.length === 0) {
return null;
}
switch (method) {
case 'mean':
return values.reduce((a, b) => a + b, 0) / values.length;
case 'sum':
return values.reduce((a, b) => a + b, 0);
case 'min':
return Math.min(...values);
case 'max':
return Math.max(...values);
case 'std':
if (values.length < 2) {
return null;
}
const mean = values.reduce((a, b) => a + b, 0) / values.length;
const variance = values.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / (values.length - 1);
return Math.sqrt(variance);
default:
return null;
}
}
/**
* Gets all unique group keys.
*
* @returns {Array} Array of group keys
* @private
*/
_getGroupKeys() {
if (this.groupingColumns.length === 1) {
return Array.from(this.groups.keys());
} else {
return this._extractGroupKeys(this.groups);
}
}
/**
* Extracts all group keys from hierarchical structure.
*
* @param {Object} obj - The group object
* @param {Array} prefix - The prefix for hierarchical keys
* @returns {Array} Array of group keys
* @private
*/
_extractGroupKeys(obj, prefix = []) {
const keys = [];
for (const key in obj) {
const value = obj[key];
const currentPrefix = [...prefix, key];
if (Array.isArray(value)) {
// Leaf node - this is a complete group key
keys.push(currentPrefix);
} else {
// Intermediate node - recurse
keys.push(...this._extractGroupKeys(value, currentPrefix));
}
}
return keys;
}
/**
* Gets row indices for a specific group key.
*
* @param {string|Array} groupKey - The group key
* @returns {Array<number>} Array of row indices in this group
* @private
*/
_getRowIndicesForKey(groupKey) {
if (this.groupingColumns.length === 1) {
return this.groups.get(String(groupKey)) || [];
} else {
// Navigate hierarchical structure
let current = this.groups;
const keyArray = Array.isArray(groupKey) ? groupKey : [groupKey];
for (const key of keyArray) {
current = current[String(key)];
if (!current) {
return [];
}
}
return Array.isArray(current) ? current : [];
}
}
/**
* Builds a result row with group key and aggregation values.
*
* @param {string|Array} groupKey - The group key
* @param {Object} aggregations - The aggregation values
* @returns {Array} The result row as an array
* @private
*/
_buildResultRow(groupKey, aggregations) {
const keyArray = Array.isArray(groupKey) ? groupKey : [groupKey];
const row = {};
// Add grouping column values
for (let i = 0; i < this.groupingColumns.length; i++) {
row[this.groupingColumns[i]] = keyArray[i];
}
// Add aggregation values
for (const colName in aggregations) {
row[colName] = aggregations[colName];
}
return row;
}
/**
* Creates a result DataFrame from aggregation results.
*
* @param {Array<Array>} results - The result rows
* @param {Object} options - Options for result creation
* @returns {DataFrame} The result DataFrame
* @private
*/
_createResultDataFrame(results, options = {}) {
// Import here to avoid circular dependency
const DataFrameFactory = require('../dataframe/dataframe');
// Build column names: grouping columns + aggregated columns
const resultColumns = [...this.groupingColumns];
if (options.count) {
resultColumns.push('count');
} else {
// Add non-grouping columns
for (const colName of this.dataframe.columns) {
if (!this.groupingColumns.includes(colName)) {
resultColumns.push(colName);
}
}
}
// Create DataFrame from results
if (results.length === 0) {
// Return empty DataFrame with correct columns
const emptyDf = DataFrameFactory([]);
emptyDf.columns = resultColumns;
emptyDf.cols = resultColumns.length;
emptyDf.setDataForColumns();
return emptyDf;
}
// Convert object rows to array rows in the correct column order
const arrayResults = results.map(row => {
return resultColumns.map(col => row[col] !== undefined ? row[col] : null);
});
return DataFrameFactory(arrayResults, resultColumns);
}
}
module.exports = GroupBy;