Skip to content

Commit 77ccbab

Browse files
authored
pandas2df: unify integer classification, add bigint option (#1)
Replace the separate int64/uint64 and object-column integer paths with a single classify_integer_strings() that tiers each column by magnitude, mirroring reticulate's py_to_r where it is faithful and reserving bit64 only where base R cannot represent a value exactly: * <= 2^31-1 -> integer * < 2^53 -> double (exact) * >= 2^53 -> integer64 This drops the uint64 special case (small uint64 count columns no longer get forced to bit64) and the object-vs-typed-path divergence. New bigint argument: 'integer64' forces every integer column to integer64 for a stable schema; 'character' returns the large tier as character (fread style). uint64 values beyond the signed 64-bit range cannot be held by integer64 and are returned as character, with a warning outside 'character' mode.
1 parent ca4fcc8 commit 77ccbab

3 files changed

Lines changed: 170 additions & 45 deletions

File tree

‎R/convert.R‎

Lines changed: 86 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -14,23 +14,37 @@
1414
#'
1515
#' @details The default in-memory path converts with reticulate and then patches
1616
#' individual columns:
17-
#' * `int64`/`uint64` columns are recovered as `bit64::integer64` so large ids
18-
#' do not overflow to `NA`.
17+
#' * Integer columns (`int64`, `uint64`, and `object` columns whose cells are
18+
#' all Python ints) are classified by magnitude so each maps to the
19+
#' narrowest faithful R type. This mirrors reticulate's own `py_to_r()`
20+
#' where reticulate is faithful, and reserves `bit64::integer64` for values
21+
#' base R cannot hold exactly -- see `bigint`.
1922
#' * `object` columns whose cells are all scalar (or `NA`) are flattened from a
2023
#' list of length-1 vectors to an atomic vector, with integer-valued cells
2124
#' read as strings so arbitrary-precision Python ints round-trip. Genuine
2225
#' list-valued columns (e.g. multi-select values) are left intact.
2326
#' * datetime columns are normalised to `POSIXct` in UTC.
2427
#'
2528
#' The optional `use_arrow` path round-trips through a Feather file and needs
26-
#' the Suggested `arrow` package.
29+
#' the Suggested `arrow` package; `bigint` does not apply to it.
2730
#'
2831
#' @param x A pandas `DataFrame` (a reticulate `pandas.core.frame.DataFrame`).
2932
#' @param use_arrow If `TRUE`, convert via a temporary Feather file using the
3033
#' `arrow` package rather than in memory. Implies a tibble result.
3134
#' @param keep_index Whether to keep the pandas index as a column.
3235
#' @param tibble Whether to return a tibble rather than a base data frame.
3336
#' Defaults to `use_arrow`.
37+
#' @param bigint How to represent integer columns. `"auto"` (the default) maps
38+
#' each column to the narrowest faithful base R type: values fitting a 32-bit
39+
#' R `integer` become `integer`, larger values below 2^53 become `double`
40+
#' (exact), and values from 2^53 up to the signed 64-bit maximum become
41+
#' `bit64::integer64` -- reserving `integer64` for what base R cannot hold
42+
#' exactly. `"integer64"` returns every integer column as `bit64::integer64`
43+
#' for a stable schema regardless of magnitude. `"character"` behaves like
44+
#' `"auto"` but returns the large (>= 2^53) tier as character, as
45+
#' `data.table::fread()` offers. `uint64` values beyond the signed 64-bit
46+
#' range cannot be held by `integer64`; they are always returned as character,
47+
#' with a warning unless `bigint = "character"`.
3448
#'
3549
#' @return An R `data.frame`, or a tibble when `tibble = TRUE`.
3650
#' @export
@@ -41,7 +55,9 @@
4155
#' pandas2df(df)
4256
#' }
4357
pandas2df <- function(x, use_arrow = FALSE, keep_index = FALSE,
44-
tibble = use_arrow) {
58+
tibble = use_arrow,
59+
bigint = c("auto", "integer64", "character")) {
60+
bigint <- match.arg(bigint)
4561
use_arrow <- isTRUE(use_arrow)
4662
keep_index <- isTRUE(keep_index)
4763
tibble <- isTRUE(tibble)
@@ -52,7 +68,7 @@ pandas2df <- function(x, use_arrow = FALSE, keep_index = FALSE,
5268
x$reset_index(drop = !keep_index, inplace = TRUE)
5369
nr <- nrow(x)
5470
if (!use_arrow)
55-
return(pandas2df_inmem(x, tibble = tibble))
71+
return(pandas2df_inmem(x, tibble = tibble, bigint = bigint))
5672
check_suggested("arrow", "for the use_arrow = TRUE conversion path")
5773
if (nr == 0L) {
5874
check_suggested("tibble", "for the use_arrow = TRUE conversion path")
@@ -83,7 +99,7 @@ is_pandas_dataframe <- function(x) {
8399
identical(nm, "DataFrame")
84100
}
85101

86-
pandas2df_inmem <- function(df, tibble = FALSE) {
102+
pandas2df_inmem <- function(df, tibble = FALSE, bigint = "auto") {
87103
if (!is_pandas_dataframe(df))
88104
stop("`df` must be a pandas DataFrame.", call. = FALSE)
89105
res <- pandas_py_to_r_frame(df)
@@ -98,9 +114,10 @@ pandas2df_inmem <- function(df, tibble = FALSE) {
98114
int_cols <- names(dtypes)[tolower(dtypes) %in% c("int64", "uint64")]
99115
for (col in intersect(int_cols, names(res))) {
100116
series <- reticulate::py_get_item(df, col)
101-
i64 <- pandas_series_integer64(series, dtypes[[col]])
102-
if (!is.null(i64))
103-
res[[col]] <- i64
117+
conv <- classify_integer_strings(pandas_series_character_values(series),
118+
bigint = bigint, col = col)
119+
if (!is.null(conv))
120+
res[[col]] <- conv
104121
}
105122

106123
# Object dtype: each cell can be an arbitrary Python object. reticulate's
@@ -130,7 +147,7 @@ pandas2df_inmem <- function(df, tibble = FALSE) {
130147
# would instead capture Python's list repr (e.g. "['AB']") as a literal
131148
# string, so leave genuine list-valued columns alone.
132149
if (pandas_series_has_list_cells(series)) next
133-
flat <- pandas_object_series_to_vector(series)
150+
flat <- pandas_object_series_to_vector(series, bigint = bigint, col = col)
134151
if (!is.null(flat))
135152
res[[col]] <- flat
136153
}
@@ -165,36 +182,69 @@ pandas_dataframe_dtypes <- function(df) {
165182
unlist(dtypes, use.names = TRUE)
166183
}
167184

168-
pandas_series_integer64 <- function(series, dtype) {
169-
vals <- pandas_series_character_values(series)
185+
# Classify a vector of exact integer strings (NA for missing) into the natural
186+
# R representation, honouring `bigint`. This is the single decision point shared
187+
# by the typed int64/uint64 path and the object-column path, so both dtypes and
188+
# both code paths behave identically.
189+
#
190+
# "auto" small -> integer, mid (< 2^53) -> double, large -> integer64
191+
# "integer64" every column -> integer64 (stable schema)
192+
# "character" as "auto" but the large (>= 2^53) tier -> character
193+
#
194+
# The tiering mirrors reticulate's py_to_r() where it is faithful (small ->
195+
# integer, mid -> double) and only reaches for bit64::integer64 once a value
196+
# exceeds 2^53, the largest integer a double represents exactly.
197+
#
198+
# uint64 values beyond the signed 64-bit maximum cannot be held by integer64
199+
# (which is signed); they are returned as character -- the only faithful option
200+
# -- and, outside "character" mode, warned about.
201+
#
202+
# Returns NULL when the caller should leave the column untouched: input is NULL,
203+
# all cells are missing, or the cells are not all integer-shaped.
204+
classify_integer_strings <- function(vals,
205+
bigint = c("auto", "integer64",
206+
"character"),
207+
col = NULL) {
208+
bigint <- match.arg(bigint)
170209
if (is.null(vals))
171210
return(NULL)
172211
present <- !is.na(vals)
173-
if (!any(present)) {
174-
if (dtype == "uint64")
175-
return(bit64::as.integer64(vals))
176-
return(NULL)
177-
}
178-
if (dtype != "uint64" &&
179-
all(abs(as.numeric(vals[present])) <= .Machine$integer.max))
212+
if (!any(present))
180213
return(NULL)
181-
if (dtype == "uint64" &&
182-
all(as.numeric(vals[present]) <= .Machine$integer.max))
183-
return(bit64::as.integer64(vals))
184214
if (!all(grepl("^-?[0-9]+$", vals[present])))
185215
return(NULL)
186-
if (any(int64_overflows(vals), na.rm = TRUE))
187-
stop("int64 overflow! id cannot be represented as int64")
188-
bit64::as.integer64(vals)
216+
217+
if (any(int64_overflows(vals), na.rm = TRUE)) {
218+
if (!identical(bigint, "character"))
219+
warning(sprintf(
220+
paste0("%s holds integers beyond the signed 64-bit range; ",
221+
"returning character. Pass bigint = \"character\" to silence."),
222+
if (is.null(col)) "A column" else sprintf("Column '%s'", col)),
223+
call. = FALSE)
224+
return(vals)
225+
}
226+
227+
i64 <- bit64::as.integer64(vals)
228+
if (identical(bigint, "integer64"))
229+
return(i64)
230+
231+
amax <- max(abs(i64), na.rm = TRUE)
232+
if (amax >= bit64::as.integer64("9007199254740992")) # 2^53
233+
return(if (identical(bigint, "character")) vals else i64)
234+
if (amax > .Machine$integer.max) # mid -> double
235+
return(as.double(i64))
236+
as.integer(i64) # small -> integer
189237
}
190238

191239
# Flatten a pandas Series of object dtype whose cells are all the same scalar
192240
# Python type (or NA) into an atomic R vector. Reads cells as strings via
193241
# series.map(str).tolist() so arbitrary-precision Python ints round-trip
194242
# without int32 truncation (the bug that corrupts uint32 columns >= 2^31
195243
# returned by the L2 cache).
196-
pandas_object_series_to_vector <- function(series) {
197-
classify_object_values(pandas_series_character_values(series))
244+
pandas_object_series_to_vector <- function(series, bigint = "auto",
245+
col = NULL) {
246+
classify_object_values(pandas_series_character_values(series),
247+
bigint = bigint, col = col)
198248
}
199249

200250
# TRUE if any non-missing cell of this pandas object Series is itself a
@@ -216,20 +266,19 @@ pandas_series_has_list_cells <- local({
216266
# already mapped via str()), classify and convert to the natural atomic R
217267
# type. Returns NULL when the column can't be classified (mixed types) and
218268
# the caller should leave the list-column intact.
219-
classify_object_values <- function(vals) {
269+
classify_object_values <- function(vals, bigint = c("auto", "integer64",
270+
"character"),
271+
col = NULL) {
272+
bigint <- match.arg(bigint)
220273
if (is.null(vals)) return(NULL)
221274
present <- !is.na(vals)
222275
if (!any(present)) return(rep(NA, length(vals))) # all-NA: caller's choice
223276

224-
# Integer-shaped strings -> numeric, or integer64 when beyond 2^53.
225-
if (all(grepl("^-?[0-9]+$", vals[present]))) {
226-
if (any(int64_overflows(vals), na.rm = TRUE)) return(NULL)
227-
nums <- suppressWarnings(as.numeric(vals))
228-
# 2^53 is the largest integer exactly representable as double; values >=
229-
# 2^53 + 1 silently round when converted via as.numeric.
230-
if (all(is.na(nums) | abs(nums) < 2^53)) return(nums)
231-
return(bit64::as.integer64(vals))
232-
}
277+
# Integer-shaped strings share the magnitude-based classifier used by the
278+
# typed int64/uint64 path, so an id column comes back the same type whether
279+
# pandas declared it int64 or handed it to us as an object of Python ints.
280+
if (all(grepl("^-?[0-9]+$", vals[present])))
281+
return(classify_integer_strings(vals, bigint = bigint, col = col))
233282

234283
# Float-shaped strings (incl. "nan", "inf", "-inf", scientific notation).
235284
# as.numeric returns NA for unparseable strings; require the parse to

‎man/pandas2df.Rd‎

Lines changed: 25 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎tests/testthat/test-convert.R‎

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ test_that("null2na replaces NULLs", {
44
})
55

66
test_that("classify_object_values picks the natural R type", {
7-
# small integers -> numeric
8-
expect_identical(classify_object_values(c("1", "2", "3")), c(1, 2, 3))
7+
# small integers -> integer (base R type, mirroring py_to_r on python ints)
8+
expect_identical(classify_object_values(c("1", "2", "3")), c(1L, 2L, 3L))
99
# big integers beyond 2^53 -> integer64
1010
big <- c("9007199254740993", "9007199254740994")
1111
out <- classify_object_values(big)
@@ -20,8 +20,63 @@ test_that("classify_object_values picks the natural R type", {
2020
c(NA, NA))
2121
# NULL passes through
2222
expect_null(classify_object_values(NULL))
23-
# int64-overflowing values -> NULL (leave the column alone)
24-
expect_null(classify_object_values(c("18446744073709551615", "1")))
23+
# int64-overflowing values -> character (faithful), with a warning
24+
expect_warning(
25+
ov <- classify_object_values(c("18446744073709551615", "1")),
26+
"beyond the signed 64-bit range")
27+
expect_identical(ov, c("18446744073709551615", "1"))
28+
})
29+
30+
test_that("classify_integer_strings tiers by magnitude (bigint = auto)", {
31+
# small (fits 32-bit) -> integer
32+
expect_identical(classify_integer_strings(c("1", "2", "-3")), c(1L, 2L, -3L))
33+
# mid (> 2^31, < 2^53) -> double, exact
34+
mid <- c("3000000000", "5000000000")
35+
out <- classify_integer_strings(mid)
36+
expect_type(out, "double")
37+
expect_identical(out, c(3e9, 5e9))
38+
# large (>= 2^53) -> integer64
39+
large <- c("9007199254740993", "720575940621039145")
40+
out <- classify_integer_strings(large)
41+
expect_s3_class(out, "integer64")
42+
expect_identical(as.character(out), large)
43+
# a single mid value in the column promotes the whole column past integer
44+
expect_type(classify_integer_strings(c("1", "3000000000")), "double")
45+
# NA and non-integer input
46+
expect_identical(classify_integer_strings(c("1", NA)), c(1L, NA))
47+
expect_null(classify_integer_strings(c("1", "x")))
48+
expect_null(classify_integer_strings(c(NA_character_)))
49+
expect_null(classify_integer_strings(NULL))
50+
})
51+
52+
test_that("classify_integer_strings honours bigint = integer64", {
53+
# every integer column -> integer64 regardless of magnitude
54+
out <- classify_integer_strings(c("1", "2"), bigint = "integer64")
55+
expect_s3_class(out, "integer64")
56+
expect_identical(as.character(out), c("1", "2"))
57+
})
58+
59+
test_that("classify_integer_strings honours bigint = character", {
60+
# small still integer, large -> character (fread style)
61+
expect_identical(classify_integer_strings(c("1", "2"), bigint = "character"),
62+
c(1L, 2L))
63+
large <- c("9007199254740993", "720575940621039145")
64+
expect_identical(classify_integer_strings(large, bigint = "character"), large)
65+
# uint64 overflow -> character, and no warning in this mode
66+
ov <- c("18446744073709551615", "1")
67+
expect_silent(out <- classify_integer_strings(ov, bigint = "character"))
68+
expect_identical(out, ov)
69+
})
70+
71+
test_that("classify_integer_strings warns and returns character on uint64 overflow", {
72+
ov <- c("18446744073709551615", "1")
73+
expect_warning(out <- classify_integer_strings(ov, col = "seg_id"),
74+
"seg_id")
75+
expect_identical(out, ov)
76+
# integer64 mode cannot hold it either -> still character + warning
77+
expect_warning(out2 <- classify_integer_strings(ov, bigint = "integer64"),
78+
"beyond the signed 64-bit range")
79+
expect_identical(out2, ov)
2580
})
2681

2782
test_that("is_posixct_list recognises a list of scalar POSIXct", {

0 commit comments

Comments
 (0)