[BUG] adorn_pct_formatting, adorn_ns and adorn_rounding overwrite a numeric first column - #1677
[BUG] adorn_pct_formatting, adorn_ns and adorn_rounding overwrite a numeric first column#1677dylanpulver wants to merge 3 commits into
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## dev #1677 +/- ##
==========================================
- Coverage 87.56% 86.02% -1.55%
==========================================
Files 95 125 +30
Lines 6819 9993 +3174
==========================================
+ Hits 5971 8596 +2625
- Misses 848 1397 +549 🚀 New features to boost your workflow:
|
|
@ericmjl thoughts? |
samukweku
left a comment
There was a problem hiding this comment.
@dylanpulver did a first pass on your PR and discovered a number of issues that I would like you to have a look at:
• The PR fixes the reported tabyl() bug, but it applies a simple rule everywhere:
“The first column is always the row label, so never process it.”
That works for a normal tabyl:
cyl | gear_3 | gear_4
----+--------+--------
4 | ... | ...
6 | ... | ...
Here cyl really is an identifier. The problem is that the code assumes every related
DataFrame has that same shape.
1. adorn_ns can silently omit counts
adorn_ns accepts a separate ns DataFrame containing the original counts.
The PR excludes the first column of ns, assuming it is the row identifier:
ns = {
"yes": [10],
"no": [5],
}
But in this example, yes is actual data—not an identifier. The PR skips it because it
happens to be first.
So this:
df = pd.DataFrame({
"id": ["A"],
"yes": [0.5],
"no": [0.5],
})
ns = pd.DataFrame({
"yes": [10],
"no": [5],
})
can produce:
id | yes | no
---+------+---------
A | 50% | 50% (5)
The yes count is missing, and no error is raised. That is why I ranked it highest:
the output looks valid but is incomplete.
The fix needs to establish one of these contracts:
-
ns must always include the same identifier column as df, and the function should
validate that; or -
adorn_ns should identify the row-label column from the target df, not blindly from
ns; or -
the function should explicitly accept a column-selection argument.
2. Empty DataFrames now crash
Before this PR, a DataFrame with no columns simply had no numeric columns to process
and was returned unchanged.
The new code does this:
first_col = df.columns[0]
But an empty DataFrame has no first column:
pd.DataFrame().columns[0]
raises IndexError.
Therefore these formerly harmless calls now fail:
pd.DataFrame().adorn_pct_formatting()
pd.DataFrame().adorn_rounding()
pd.DataFrame().adorn_ns(ns=pd.DataFrame())
This is a straightforward regression. The functions should return unchanged data when
there are no columns, or check for an empty column list before accessing position
zero.
3. Duplicate column names do not follow the claimed positional rule
The PR describes the identifier as positional: “the first column.”
However, the implementation compares column labels:
if col != first_col
Consider:
df = pd.DataFrame(
[[1, 2, 0.5]],
columns=["value", "value", "ratio"],
)
The first "value" is the row identifier, but the second "value" is a data column.
Because both have the same label, both are excluded.
So the implementation behaves as though the rule were:
“Ignore every column with the same name as the first column.”
That is different from:
“Ignore only column position zero.”
If duplicate column labels are intentionally unsupported, the project should validate
or document that. If they are supported, the code needs positional selection.
4. Broader behavior change for ordinary DataFrames
Previously, adorn_rounding, adorn_pct_formatting, and adorn_ns processed numeric
columns regardless of position.
After this PR:
df = pd.DataFrame({
"year": [2020, 2021],
"value": [1.234, 2.345],
})
adorn_rounding() leaves year untouched because it assumes year is a row label.
That is correct for a tabyl, but not necessarily for a normal DataFrame where the
first column is ordinary numeric data. The PR acknowledges this for rounding, so it
is not an undiscovered bug, but it is an API behavior change that should be
deliberate and documented clearly.
Addresses review on pyjanitor-devs#1677. adorn_ns took its column list from the `ns` frame and dropped that frame's first column. A counts frame passed through `ns` need not repeat the identifier column of `df`, so a real count in position 0 was discarded with no error. The identifier belongs to the frame being adorned, so it is now read from `df` and every numeric column of `ns` is treated as a count. This matches R janitor's adorn_ns(), which restores column index 1 of `dat` and does not derive its column set from a user-supplied `ns`. The identifier was described as positional but implemented as a label comparison, so a frame with duplicate column labels exempted every column sharing the first column's name. Selection is now positional throughout the adorn_* family, matching R janitor's `setdiff(numeric_cols, 1)`. A DataFrame with no columns raised IndexError on `df.columns[0]`. Such a frame is now returned unchanged.
100e4bc to
22f4049
Compare
|
@samukweku thanks for the pass, and noted you pulled in @ericmjl. Three of the four were bugs we introduced. All four are fixed in 22f4049, rebased onto current 1. adorn_ns dropping countsOur bug, and the ranking was right. Fixed with your second option: the row identifier is read from the target R janitor puts it there. When a caller supplies their own if (custom_ns_supplied & rlang::dots_n(...) == 0) {
dont_adorn <- 1L
} else if (rlang::dots_n(...) == 0) {
cols_to_adorn <- numeric_cols
dont_adorn <- setdiff(1:ncol(dat), cols_to_adorn)
dont_adorn <- unique(c(1, dont_adorn)) # always don't-append first column
}
Your first option is what R enforces, via Two tests:
2. Empty DataFramesOur regression, guarded.
3. Duplicate column labelsOur bug. The description said positional and the code compared labels. Selection is positional now, everywhere in the family, which is what R does on integer indices: numeric_cols <- which(vapply(dat, is.numeric, logical(1)))
numeric_cols <- setdiff(numeric_cols, 1) # assume 1st column should not be included so remove it from numeric_colsThat idiom appears with its comment intact in A Three tests:
4. Documented behavior changeEvery function in the family states the rule in its docstring now, in the same words: position 0 is the row identifier and is not modified even when numeric. On a DataFrame that is not a frequency table, a numeric first column is left alone.
The helper being shared means VerificationRebased onto The docs job failure is the gh-pages preview deploy returning 403 for fork PRs. That job failed the same way on the previous commit. |
samukweku
left a comment
There was a problem hiding this comment.
@dylanpulver, thanks for addressing the first review. I did a fresh pass against 22f4049 and found one remaining correctness issue that should be fixed before merge.
adorn_ns silently reuses the first count when labels are duplicated
The positional handling works in adorn_totals, adorn_percentages, adorn_pct_formatting, and adorn_rounding, but adorn_ns converts the numeric columns of ns into a one-position-per-label dictionary:
ns_positions = {}
for pos in _numeric_positions(ns):
ns_positions.setdefault(ns.columns[pos], pos)setdefault retains only the first position for a repeated label. Every matching column in df then reads that same count position, so duplicate labels produce plausible-looking but incorrect counts.
Explicit ns example
df = pd.DataFrame(
[["A", "50.0%", "50.0%"]],
columns=["id", "yes", "yes"],
)
ns = pd.DataFrame([[10, 20]], columns=["yes", "yes"])
result = df.adorn_ns(ns=ns)Actual:
id yes yes
A 50.0% (10) 50.0% (10)
The second count should come from the second yes column:
id yes yes
A 50.0% (10) 50.0% (20)
Stored-counts pipeline
The same problem occurs with _original_counts, and can cause the row identifier itself to be used as the count:
df = pd.DataFrame([[100, 10, 20]], columns=["x", "x", "x"])
result = (
df.adorn_percentages("row")
.adorn_pct_formatting()
.adorn_ns()
)Actual:
x x x
100 33.3% (100) 66.7% (100)
Expected:
x x x
100 33.3% (10) 66.7% (20)
This matters because the PR and changelog explicitly establish positional duplicate-label support. On dev, these cases raise an ambiguity error; this change instead returns silently corrupted output.
Please either:
- align
nsby position when it has the same schema asdf, and define occurrence-aware matching for customns; or - reject ambiguous duplicate-label inputs to
adorn_nswith a clearValueError.
Please also add regression coverage for both an explicit duplicate-label ns and the full stored-counts pipeline above.
adorn_pct_formatting, adorn_ns and adorn_rounding selected every numeric column, so a numeric row identifier was formatted as a percentage, had an N count appended, or was rounded. adorn_totals and adorn_percentages already exclude the first column. Apply the same exclusion at the three remaining sites and add tests for a numeric first column, including a tabyl pipeline on an integer grouping variable.
Addresses review on pyjanitor-devs#1677. adorn_ns took its column list from the `ns` frame and dropped that frame's first column. A counts frame passed through `ns` need not repeat the identifier column of `df`, so a real count in position 0 was discarded with no error. The identifier belongs to the frame being adorned, so it is now read from `df` and every numeric column of `ns` is treated as a count. This matches R janitor's adorn_ns(), which restores column index 1 of `dat` and does not derive its column set from a user-supplied `ns`. The identifier was described as positional but implemented as a label comparison, so a frame with duplicate column labels exempted every column sharing the first column's name. Selection is now positional throughout the adorn_* family, matching R janitor's `setdiff(numeric_cols, 1)`. A DataFrame with no columns raised IndexError on `df.columns[0]`. Such a frame is now returned unchanged.
adorn_ns keyed its count lookup by label, keeping a single position per label, so every column of df sharing a label read the same count. Counts that share the column axis of df are now matched across by position (the stored-counts path, where the identifier would otherwise stand in as a count), and a label supplied through ns is otherwise consumed occurrence by occurrence.
22f4049 to
57a8de4
Compare
|
Fixed, and both your reproductions now give the output you specified.
Your two cases: Regression coverage for both, 56 tests in |
PR Description
A tabyl on a numeric grouping variable comes back with its row labels rewritten as data:
cylreturns as400.0%,600.0%,800.0%rather than 4, 6, 8. Nothing warns.adorn_totalsandadorn_percentagesskip the first column as a row identifier. This applies that exclusion at the three sites that did not.Constraint: the identifier is positional and belongs to the frame being adorned, so
adorn_nsreads it fromdfand treats every numeric column ofnsas a count. R janitor does the same, restoring column index 1 ofdatand never deriving a column set from a user-suppliedns.Rejected: a dtype or name heuristic for identifiers. It would disagree with
adorn_totalson the same frame, and R janitor scopes the whole family to "all numeric columns besides the initial column".Residual limitation: on a plain DataFrame whose first column holds data,
adorn_roundingstops rounding it. That is the tradeoffadorn_totalsandadorn_percentagesmake today.Measurement: 1284 passed against 1266 on
dev, same pre-existing failures, doctests identical. Each finding has a test that fails when its hunk is reverted on its own. One test is a pin rather than new coverage: the plain-DataFrame rounding test passes either way and records intent.This PR resolves #1676.
PR Checklist
CHANGELOG.mdunder the latest version header.