Sum or average a column of data
Add up, count or average a column of a file, without one unreadable row quietly skewing the total.
No AI needed
FreeInstantYour data never leaves
The verdict
N0, and the only thing worth writing is what goes around the addition. The digits are read as written, lined up on the column’s largest number of decimals, and added as integers: the sum is exact over ten thousand rows as over six. The comparison is in the test — over six ordinary invoice amounts, adding in floats returns 244.98000000000002 where the invoice prints 244.98. And the classic answer to that problem, Kahan’s compensated sum or Python’s fsum, returns exactly the same wrong value: the error is not in the accumulation, it is in the representation, since “83.87” is already not 83.87 in binary. The rest is discipline about counting: every row that is not a number comes back with its number and its reason, a column with no number at all returns nothing rather than zero, and the mean — the only result that can need rounding — is returned with the rounding rule stated, the exact sum and the count beside it.
An integer addition over the digits written, and a count of what was left out
No AI needed
Rule and classic algorithmN0FreeInstant
Python
"""
Add up a column of a file, exactly, and say what was left out.
Rung N0. Adding is what a computer did before it did anything else, so the
interesting part of this job is everywhere except the addition.
It is in the arithmetic, first. A column of money added as floating-point
numbers drifts: this entry's test adds one cent ten thousand times and shows
the result is not one hundred euros. So nothing here becomes a float. A value
is read as the digits that were written, all the values are lined up on the
same number of decimals, and the sum is an integer addition — exact, whatever
the length of the column.
It is in what is not a number, second. A real column has an empty cell, a
« n/a », a total line, a value with a currency symbol. Skipping them quietly is
how an average ends up being computed over eight hundred rows out of a
thousand without anyone noticing. Every value that could not be read comes back
with its row number and the reason.
And it is in the average, which is the only result that can need rounding: it
is returned two decimals beyond the data, rounded away from zero, and both the
exact sum and the count come back so a caller can round otherwise.
"""
from __future__ import annotations
import re
# What a number looks like in a file written for a program: a sign, digits, and
# at most one decimal separator. Grouping separators are not read here — the
# entry on extracting amounts from a text is where « 1 250,00 » is settled.
NUMBER = re.compile(r"\A[+-]?[0-9]+(?:[.,][0-9]+)?\Z")
# The two decimal signs this rung knows. The caller declares one; a value that
# uses the other is not misread, it is refused and counted.
SIGNS = (".", ",")
# How many decimals beyond the data's own the average is given with.
MEAN_EXTRA = 2
def aggregate(values, decimal_sign: str = ".") -> dict:
"""
The count, the exact sum, the average and the bounds of a column.
`values` is what a file gives: text, one item per row. Everything that is
not a number comes back in `skipped` rather than being passed over.
"""
if decimal_sign not in SIGNS:
return _report(None, [], f"the decimal sign must be one of {' '.join(SIGNS)}")
if not isinstance(values, (list, tuple)):
# A text is iterable and is not a column: taken as one, it would be
# read letter by letter.
return _report(None, [], "expected a list of values")
rows = list(values)
read, skipped, scale = [], [], 0
for index, value in enumerate(rows):
# A boolean is an integer in Python and is not one anywhere else: it
# is refused on both sides rather than counted as 0 or 1.
numeric = isinstance(value, (int, float)) and not isinstance(value, bool)
text = value.strip() if isinstance(value, str) else str(value) if numeric else None
if text is None or not NUMBER.match(text) or _other_sign(text, decimal_sign):
skipped.append({"row": index, "value": value, "why": _why(value, decimal_sign)})
continue
digits, places = _scaled(text, decimal_sign)
read.append((digits, places))
scale = max(scale, places)
if not read:
return _report({"count": 0, "sum": None, "mean": None, "minimum": None,
"maximum": None, "decimals": 0}, skipped, None)
aligned = [digits * 10 ** (scale - places) for digits, places in read]
total = sum(aligned)
mean = _half_away(total * 10 ** MEAN_EXTRA, len(aligned))
return _report({"count": len(aligned), "sum": _text(total, scale),
"mean": _text(mean, scale + MEAN_EXTRA),
"minimum": _text(min(aligned), scale),
"maximum": _text(max(aligned), scale),
"decimals": scale}, skipped, None)
def _scaled(text: str, sign: str):
"""The digits that were written, as an integer, and how many decimals."""
whole, _, fraction = text.replace(sign, ".").partition(".")
return int(f"{whole}{fraction}"), len(fraction)
def _other_sign(text: str, sign: str) -> bool:
return any(other in text for other in SIGNS if other != sign)
def _why(value, sign: str) -> str:
if not isinstance(value, str) and not (isinstance(value, (int, float))
and not isinstance(value, bool)):
return "not text"
text = str(value).strip()
if not text:
return "empty"
if _other_sign(text, sign):
return f"written with « {SIGNS[0] if sign == SIGNS[1] else SIGNS[1]} » as the decimal sign"
return "not a number"
def _half_away(numerator: int, denominator: int) -> int:
"""Rounded away from zero on a half, which is the rule stated in the docstring."""
sign = -1 if numerator < 0 else 1
return sign * ((2 * abs(numerator) + denominator) // (2 * denominator))
def _text(digits: int, places: int) -> str:
"""The integer written back with its decimal point, and no float on the way."""
sign = "-" if digits < 0 else ""
body = str(abs(digits)).rjust(places + 1, "0")
return f"{sign}{body}" if places == 0 else f"{sign}{body[:-places]}.{body[-places:]}"
def _report(figures, skipped, reason) -> dict:
return {"figures": figures, "skipped": skipped, "reason": reason}JavaScript
/**
* Add up a column of a file, exactly, and say what was left out.
*
* Rung N0. Adding is what a computer did before it did anything else, so the
* interesting part of this job is everywhere except the addition.
*
* It is in the arithmetic, first. A column of money added as floating-point
* numbers drifts: this entry's test adds six ordinary amounts and shows the
* answer is not the one the invoice prints. So nothing here becomes a float. A
* value is read as the digits that were written, all the values are lined up
* on the same number of decimals, and the sum is an integer addition — exact,
* whatever the length of the column.
*
* It is in what is not a number, second. A real column has an empty cell, a
* « n/a », a total line, a value with a currency symbol. Skipping them quietly
* is how an average ends up being computed over eight hundred rows out of a
* thousand without anyone noticing. Every value that could not be read comes
* back with its row number and the reason.
*
* And it is in the average, which is the only result that can need rounding:
* it is returned two decimals beyond the data, rounded away from zero, and
* both the exact sum and the count come back so a caller can round otherwise.
*/
// What a number looks like in a file written for a program: a sign, digits,
// and at most one decimal separator. Grouping separators are not read here —
// the entry on extracting amounts from a text is where « 1 250,00 » is settled.
const NUMBER = /^[+-]?[0-9]+(?:[.,][0-9]+)?$/;
// The two decimal signs this rung knows. The caller declares one; a value that
// uses the other is not misread, it is refused and counted.
export const SIGNS = ['.', ','];
// How many decimals beyond the data's own the average is given with.
export const MEAN_EXTRA = 2;
/**
* The count, the exact sum, the average and the bounds of a column.
*
* `values` is what a file gives: text, one item per row. Everything that is
* not a number comes back in `skipped` rather than being passed over.
*/
export function aggregate(values, decimalSign = '.') {
if (!SIGNS.includes(decimalSign)) {
return report(null, [], `the decimal sign must be one of ${SIGNS.join(' ')}`);
}
if (!Array.isArray(values)) {
// A text is iterable and is not a column: taken as one, it would be read
// letter by letter.
return report(null, [], 'expected a list of values');
}
const read = [];
const skipped = [];
let scale = 0;
values.forEach((value, index) => {
// A boolean is an integer in Python and is not one anywhere else: it is
// refused on both sides rather than counted as 0 or 1.
const numeric = typeof value === 'number' && Number.isFinite(value);
const text = typeof value === 'string' ? value.trim() : (numeric ? String(value) : null);
if (text === null || !NUMBER.test(text) || otherSign(text, decimalSign)) {
skipped.push({ row: index, value, why: why(value, decimalSign) });
return;
}
const [digits, places] = scaled(text, decimalSign);
read.push([digits, places]);
scale = Math.max(scale, places);
});
if (read.length === 0) {
return report({
count: 0, sum: null, mean: null, minimum: null, maximum: null, decimals: 0,
}, skipped, null);
}
const aligned = read.map(([digits, places]) => digits * 10n ** BigInt(scale - places));
const total = aligned.reduce((sum, item) => sum + item, 0n);
const mean = halfAway(total * 10n ** BigInt(MEAN_EXTRA), BigInt(aligned.length));
const least = aligned.reduce((low, item) => (item < low ? item : low));
const most = aligned.reduce((high, item) => (item > high ? item : high));
return report({
count: aligned.length,
sum: asText(total, scale),
mean: asText(mean, scale + MEAN_EXTRA),
minimum: asText(least, scale),
maximum: asText(most, scale),
decimals: scale,
}, skipped, null);
}
/** The digits that were written, as an integer, and how many decimals. */
function scaled(text, sign) {
const [whole, fraction = ''] = text.replace(sign, '.').split('.');
return [BigInt(`${whole}${fraction}`), fraction.length];
}
function otherSign(text, sign) {
return SIGNS.some((other) => other !== sign && text.includes(other));
}
function why(value, sign) {
if (typeof value !== 'string' && !(typeof value === 'number' && Number.isFinite(value))) {
return 'not text';
}
const text = String(value).trim();
if (!text) return 'empty';
if (otherSign(text, sign)) {
const other = sign === SIGNS[1] ? SIGNS[0] : SIGNS[1];
return `written with « ${other} » as the decimal sign`;
}
return 'not a number';
}
/** Rounded away from zero on a half, which is the rule stated in the docstring. */
function halfAway(numerator, denominator) {
const sign = numerator < 0n ? -1n : 1n;
const size = numerator < 0n ? -numerator : numerator;
return sign * ((2n * size + denominator) / (2n * denominator));
}
/** The integer written back with its decimal point, and no float on the way. */
function asText(digits, places) {
const sign = digits < 0n ? '-' : '';
const body = (digits < 0n ? -digits : digits).toString().padStart(places + 1, '0');
return places === 0 ? `${sign}${body}`
: `${sign}${body.slice(0, -places)}.${body.slice(-places)}`;
}
function report(figures, skipped, reason) {
return { figures, skipped, reason };
}Try it
interactiveThe code above runs in your browser. Type in the box: the result is redone in front of you.
Nothing goes out on the network: the adding happens in your browser, on integers. The “in floats” line is the same column added the way ordinary code would.
Or start from an example
Free typing and the other examples need JavaScript. The example shown below was computed when the site was built.
What it returns
6 row(s) read
In detail
| What the code returns | Value |
|---|---|
| Rows read | 6 |
| Exact sum | 244.98 |
| The same sum in floats | 244.98000000000002 |
| Mean of the rows read | 40.8300 |
| Smallest and largest | 1.38 … 83.87 |
Here it breaks
What this means for you
- Your data stays put
- Always the same result
- Unit-testable, case by case
- No provider in the loop
- Negligible footprint
What this adds to your regulatory scope
- No specific scope added: the column is read inside your own process and is sent to nobody
- A file of turnover or of salaries stays with you, which is the only thing this level asks
Breaking point
The mean covers the rows read, not the rows of the file: in the test, three of the column’s seven rows are not numbers, and the mean returned is that of the other four. Nothing can guess this for you — an empty row may be a zero, it may be a missing value, and the two do not give the same mean. The witness is in the same test: every skipped row comes back with its number and its reason, and the count of rows read is returned beside the mean; a mean published without that count is a mean nobody knows the base of.
When to move up a level
There is no level above this one. The day the column no longer fits in memory, a model is not what you need: it is the same addition, done by the database that already holds the rows.
The four options
- RecommendedN0An integer addition over the digits written, and a count of what was left outFree · Instant
- N1Not worth it here
- N2Not worth it here
- N3Not worth it here
The other options
N1Not worth it here
Not worth it here
Lightweight classic modelN1
There is nothing to learn, and nothing to approximate: a sum has an exact value, and a model estimating it to within one per cent would be wrong from the first row to the last. The one decision on this page — what to do with a row that is not a number — is not a statistical decision: it belongs to the person who knows what that file holds.
N2Not worth it here
Not worth it here
Small self-hosted specialised modelN2
The same argument, with budget on top. Loading a resident model to add up a column would mean paying for a machine to redo, less well, the operation the processor performs in a single instruction.
N3Not worth it here
Not worth it here
General-purpose LLM APIN3
This is the textbook case for this site. A general-purpose model returns a number that looks like a total, without having computed it, and the only way to check its answer is to do the sum — that is, to do the very thing you wanted to hand over. Add to that sending the file itself, which is often the file you did not want to send.
Further reading
- Python — « Floating-Point Arithmetic: Issues and Limitations », la page qui explique pourquoi 0,1 n’est pas 0,1
- Python — le module decimal, la réponse de la bibliothèque standard
- TC39 — la proposition Decimal pour JavaScript, qui n’a pas encore de type décimal
- Extraire les montants d’un texte — la fiche qui lit « 1 250,00 » avant que celle-ci ne l’additionne
Where the figures come from
- Python, module decimal (consulté) : « Decimal numbers can be represented exactly » — la bibliothèque standard décrit elle-même ce que le binaire ne fait pas
- Python, math.fsum (consulté) : « Return an accurate floating-point sum of values » — la somme compensée corrige l’accumulation, et la mesure de cette fiche montre qu’elle ne corrige pas la représentation
- TC39, proposition Decimal (consultée) : JavaScript n’a pas de type décimal, ce qui est la raison pour laquelle l’extrait additionne des `BigInt` plutôt que d’emprunter un type
- MDN, Number.MAX_SAFE_INTEGER (consulté) : au-delà de 2^53, un entier n’est plus représentable exactement en nombre JavaScript ordinaire, d’où les `BigInt`
Metadata
General information, kept up to date as best we can. It is not legal advice. Have a professional review your situation.