Skip to content

Support Python 3.13 / NumPy 2 / pandas 3, and fix the correctness bugs it exposed - #1372

Open
ran-haim wants to merge 10 commits into
vertica:masterfrom
ran-haim:py313-modern-deps
Open

Support Python 3.13 / NumPy 2 / pandas 3, and fix the correctness bugs it exposed#1372
ran-haim wants to merge 10 commits into
vertica:masterfrom
ran-haim:py313-modern-deps

Conversation

@ran-haim

@ran-haim ran-haim commented Aug 31, 2026

Copy link
Copy Markdown

Brings VerticaPy up to Python 3.13 / NumPy 2 / pandas 3 / Matplotlib 3.11, and fixes the correctness bugs that running the live-Vertica suite against those versions exposed.

10 commits, 38 files, +961 / -355.

Python 3.13 and modern dependency floors

cp313 has no wheels for numpy<2, pandas<2.2, scipy<1.14 or matplotlib<3.9, so the floors rise and the code has to work against those majors.

  • np.float_ removed in NumPy 2.0 — TableSample.category() raised AttributeError on any float column.
  • Series.ravel removed in pandas 3.0 — 93 test failures; replaced with np.ravel(), correct on both 2.2 and 3.0.
  • Axes.boxplot(labels=) removed in Matplotlib 3.11 — every box plot raised TypeError.
  • pandas 3 represents string NA as nan (truthy), so 1 if x else 0 and row is not None silently stopped detecting NULLs.
  • python_requires >= 3.10, 3.13 classifier, ipython declared (it was imported at package-import time but never listed), version single-sourced from __init__.py, tox/CI matrices cover 3.10–3.13.
  • 25 invalid escape sequences fixed (SyntaxWarning since 3.12, a future SyntaxError). All 46,727 string constants diffed before/after — bytewise unchanged.

Data-corruption fixes in read_pandas

  • Literal double quotes were silently stripped: say "hi" round-tripped as say hi. A blanket .replace('""', "") deleted the doubling that escaped a real quote. The CSV is now enclosed with a control character instead, so " is ordinary data.
  • The insert path declared ESCAPE AS '\' while to_csv writes escapes as \027, so escapes were never decoded and a literal backslash was eaten: C:\dir loaded as C:dir.
  • Under pandas 3, string columns went undetected (dedicated string dtype, not object) and so were never quoted — a value containing the separator landed in the wrong columns.
  • to_csv() wrote \n records in text mode, so on Windows the files came out CRLF and COPY mis-parsed them: 528 of 1234 rows survived a round trip.
  • An enclosure-character collision is now detected from COPY's accepted-row count and raised, naming dtype as the way through, rather than silently dropping rows.

One deliberate behaviour change: '' no longer loads as NULL

An empty string used to round-trip as NULL, because the blanket .replace('""', "") erased it and the NULL representation alike. Dropping that replace — the same removal that stops say "hi" losing its quotes — lets the two stay distinct.

The enclosure is what carries the difference. COPY … NULL '' matches a field that is empty and unenclosed; an empty string is written as a zero-length enclosed value, which does not match:

DataFrame value bytes in the intermediate CSV COPY loads
"text" \x16text\x16 'text'
"" \x16\x16 ''
None / NaN (empty field) NULL
say "hi" \x16say "hi"\x16 say "hi"

A None/NaN never acquires the enclosure characters in the first place: the value is built as ENCLOSED_BY + col.str.slice() + ENCLOSED_BY, and string concatenation with NA propagates NA, so it stays NA and to_csv writes it as an empty field. All-NULL columns are set to "" without enclosure for the same effect, and non-string columns are untouched.

This distinction holds only where the fields are enclosed. Passing dtype sets enclose = False, and an empty string is then indistinguishable from an empty field and still loads as NULL — with no enclosure there is no byte left to separate "empty" from "absent". Nothing pinned the old behaviour in either direction.

Test-harness defects (wrong oracles, not flaky tests)

  • conftest.py assigned the same precision-recall trapezoid to both auc and prc_auc; roc_auc_score appeared nowhere in the file, while the VerticaPy side of the auc assertion reads Vertica's ROC AUC. The ~3% gap was masked by a tolerance fitted to it. Present since PR Unit test report tolerance at metric level #1157.
  • XGBInitializer silently dropped xgboost-spelled kwargs, so the reference model trained with eta=0.3 instead of 0.1. Unrecognized kwargs are now rejected rather than ignored.
  • The harness hardcoded tree_method="exact", a different split-finding algorithm from Vertica's, which also ignored max_bin entirely. Mapped split_proposal_method='global'tree_method='approx'; gap −5.04% → −4.08%.
  • remove_model_dir() was a silent no-op on Windows (test -d resolved to Git's MSYS test.exe; sudo rm -rf doesn't exist), so cleanup never ran and EXPORT_MODELS refused to overwrite — 137 failures, matching the arithmetic exactly.
  • rolling().sem(ddof=0) compensated for a pandas 2 bug that 3.0 fixed; the test had been checking correct SQL against a wrong oracle.

No tolerance was widened anywhere. Six assertions that compare a Vertica-trained model against a scikit-learn-trained one are marked xfail(strict=True) — the sklearn reference's own seed-to-seed spread (1.89% against a 0.40% tolerance) means no tolerance value is reachable. Loosening one is what hid the auc defect for two years.

New tests isolate what is actually VerticaPy's to get right: test_metric_matches_sklearn_on_same_probabilities feeds both implementations the same probability vector (worst relative difference 2.7e-05 against a 1e-3 tolerance), and test_predict_matches_argmax_of_predict_proba pins an invariant nothing else covered.

Other

  • vDataFrame.corr(focus=...) sorted by abs(...), and a NaN correlation (any all-NULL column) compares False against everything, so Timsort emitted an arbitrary order — the titanic plot was genuinely mis-ranked, with parch (r=.087) shown as less correlated than age (r=.042).
  • CLAUDE.md documents how to run the live-Vertica suite in Docker, which is no longer obvious: Community Edition was pulled from Docker Hub and is unsupported from Vertica 26.1, so CI's opentext/vertica-ce pull cannot succeed.

Test results (live Vertica 25.3, Python 3.13, NumPy 2.5, pandas 3.0)

Suite Result
tests_new/core 793 passed, 2 skipped, 0 failed
tests_new/plotting 923 passed, 83 skipped, 0 failed (was 8 failed)
tests_new/performance 133 passed, 86 skipped, 0 failed
test_tree_model.py 93 failed → 4 failed, 317 passed
test_model_management.py 137 failed → 163 passed, 0 failed

machine_learning (2,084 tests) was run in full — 1,559 passed, 231 failed, 294 skipped (2h28m). That run is the source of the 231 failures diagnosed above, which resolved into three causes: 137 from remove_model_dir being a silent no-op on Windows, 93 from Series.ravel, and a handful of pre-existing metric disagreements.

After the fixes, the two affected files were re-run: test_model_management.py 137 failed → 163 passed, 12 skipped, 0 failed, and test_tree_model.py 93 failed → 317 passed, 4 failed, 26 skipped. Those last 4, plus 2 in test_base_model_methods.py, are the assertions subsequently marked xfail(strict=True).

A whole-suite re-run with every fix in place has not been captured, so the numbers above are the per-file re-runs rather than a single final tally.


🤖 Generated with Claude Code

https://claude.ai/code/session_018dGoxha5DN2QzfZV8HbLkb

ran-haim and others added 10 commits August 27, 2026 19:41
Supporting 3.13 and supporting NumPy 2 are the same task: cp313 has no
wheels for numpy<2, pandas<2.2, scipy<1.14 or matplotlib<3.9, so the
floors have to rise and the code has to work against those majors.

Library fixes:
- np.float_ was removed in NumPy 2.0, so TableSample.category() raised
  AttributeError on any float column. Use np.float64 (an exact alias in
  NumPy 1.x, so this stays correct on both).
- read_pandas() failed to detect string columns under pandas 3, which
  infers a dedicated string dtype instead of object. Undetected string
  columns are never quoted, so a value containing the separator was
  ingested into the wrong columns.
- read_pandas() also needs quotechar=None: pandas >= 3.0 escapes the
  quotechar even under QUOTE_NONE, turning the quotes added for COPY's
  ENCLOSED BY into literal data. pandas 2.x output is unchanged.
- to_csv() built its records with "\n" but wrote in text mode, so on
  Windows the files came out CRLF and COPY (record terminator "\n")
  mis-parsed them - 528 of 1234 rows survived a round trip. Write with
  newline="".

Packaging:
- python_requires >= 3.10 (3.9 is EOL and blocks numpy>=2.1), 3.13
  classifier, modern floors, and ipython added to install_requires - it
  is imported at package-import time but was never declared.
- setup.py read README.md with no encoding, failing on non-UTF-8
  locales, and hardcoded a version that could drift from __init__.py.
  It now single-sources the version by parsing it.
- Version 1.2.0: dropping 3.9 and raising floors is not a patch, and
  PyPI already holds a different 1.1.1.
- tox/CI matrices cover 3.10-3.13; tensorflow>=2.18 replaces the 2.15.1
  pin, which has no cp312/cp313 wheels, removing the !py312 exclusion.

Test suite (tests_new/core: 786 passed, 0 failed):
- pandas 3 represents string NA as nan, not None, and nan is truthy -
  "1 if x else 0" and "row is not None" silently stopped detecting
  NULLs. Use notna()/fillna().
- groupby(by).last(columns) passed a column name into last()'s first
  positional parameter, numeric_only; pandas 3 validates it.
- rolling().sem(ddof=0) compensated for a pandas 2 bug that 3.0 fixed.
  VerticaPy computes STDDEV/SQRT(COUNT) with a sample STDDEV, so ddof=1
  is the matching definition; verified element-wise against a live
  server. The test had been checking correct SQL against a wrong oracle.
- pytest 9 rejects approx() on datetimes without a tolerance, and no
  longer accepts a trailing comma in parametrize argnames.
- Shared normalize_na() helper for None-vs-nan comparisons.

Also fixes 25 invalid escape sequences (SyntaxWarning since 3.12, a
future SyntaxError). These are LaTeX in docstrings; the backslashes are
doubled rather than made raw, so every string value is bytewise
unchanged - verified by diffing all 46,727 string constants before and
after.

CLAUDE.md documents how to run the live-Vertica suite in Docker, which
is no longer obvious: Community Edition was removed from Docker Hub and
is unsupported from Vertica 26.1, so the CI's opentext/vertica-ce pull
cannot succeed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both found by running the plotting suite against a live Vertica server.

Axes.boxplot(labels=) was renamed tick_labels in Matplotlib 3.9 and
removed in 3.11, so every box plot raised TypeError. That accounted for
7 of the 8 plotting failures.

vDataFrame.corr(focus=...) sorted its results with key=abs(tup[1]).
A NaN correlation - which any all-NULL column produces - makes abs()
return NaN, and NaN compares False against everything, so Timsort
emitted an arbitrary order. With the titanic dataset's all-NULL "body"
column the rendered plot was genuinely mis-ranked:

  before: survived, pclass, fare, age, body(NaN), parch, sibsp
  after:  survived, pclass, fare, parch, age, sibsp, body(NaN)

parch (r=.087) was being displayed as less correlated than age (r=.042),
and the NaN sat in the middle of the ranking. Rank NaN below every real
magnitude so it lands last, deterministically.

tests_new/plotting: 923 passed, 83 skipped, 0 failed (was 8 failed).

CLAUDE.md documents the live-Vertica setup, and records that the
performance suite's 36 failures are a missing Graphviz `dot` binary -
the Python "graphviz" package is only the binding and does not bundle
the executables. docs/superpowers/plans/ carries the running plan.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both found by running tests_new/machine_learning against a live Vertica
server, which resolved 231 failures into three causes.

pandas removed Series.ravel in 3.0 (deprecated in 2.2), breaking 93
tests. Use np.ravel(), which accepts Series and ndarray alike and so is
correct on both pandas 2.2 and 3.0. Only the calls actually made on a
Series were changed; confusion_matrix(...).ravel() and
.to_numpy().ravel() operate on numpy arrays and are left alone.
test_tree_model.py: 93 failed -> 4 failed, 317 passed.

remove_model_dir() was a silent no-op on Windows. It shelled out to
"test -d", which resolves to Git's /usr/bin/test.exe and interprets a
POSIX-looking /tmp/... path against the MSYS root rather than C:\tmp, so
the existence check always reported "does not exist" and the removal
branch never ran. Even reached, it called "sudo rm -rf", which does not
exist on Windows. The helper only prints and never raises, so both legs
failed silently. Cleanup therefore never happened and EXPORT_MODELS
refused to overwrite the leftover directory.

The arithmetic confirms it: 13 model classes x 4 categories, minus 2
pmml skips, is 50 exports; the first category per class creates the
directory and passes, so 37 fail. import and load run after the
directory exists and fail 50 each. 37 + 50 + 50 = 137, the exact
observed count.

Replaced with os.path.isdir + shutil.rmtree, keeping the original
sudo rm -rf as the POSIX fallback for the case where the directory is
owned by the server's dbadmin user. Where no filesystem is shared with
the server, isdir returns False and the helper no-ops exactly as before.
test_model_management.py: 137 failed -> 163 passed, 0 failed.

The 4 remaining prc_auc/RandomForestClassifier failures are NOT from
this port: they produce byte-identical values under pandas 2.2.3 and
3.0.5, and were previously masked by the ravel failure aborting the test
first. Their tolerance is left untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
conftest.py assigned the same precision-recall trapezoid to both "auc"
and "prc_auc", and roc_auc_score appeared nowhere in the file:

    classification_metrics_map["auc"]     = skl_metrics.auc(recall, precision)
    classification_metrics_map["prc_auc"] = skl_metrics.auc(recall, precision)

The VerticaPy side of the "auc" assertion reads Vertica's ROC AUC, so
the test has been comparing two different quantities. On titanic the two
sit roughly 3% apart, which is precisely the rel=0.03 the auc tolerance
was fitted to, so the wrong reference was being masked by a tolerance
widened to accommodate it.

Effect across tests_new/machine_learning/vertica: 14 auc assertions now
compare ROC AUC against ROC AUC and pass.

One test newly fails, and it is a true positive rather than a
regression:

    test_score[vpy_metric_name0-auc-XGBClassifier]
    assert 0.8517505750063886 == 0.9104310418263907 +/- 0.0455216

Vertica's XGB ROC AUC is about 6% below scikit-learn's. That gap was
previously hidden because sklearn's PR AUC (~0.85) happened to fall
within tolerance of Vertica's ROC AUC. Its tolerance is deliberately NOT
widened: the tolerance table is machine-generated from whatever passed
at the time (log_loss sits at 9e10), and VerticaPy's own documentation
states the policy as 1% for regression and 10% for classification.
Re-deriving these bounds is a maintainer decision, and loosening one to
force green is what let this defect survive since PR vertica#1157.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
XGBInitializer silently dropped xgboost-spelled kwargs (only max_depth
and learning_rate survived by name collision with the Vertica spelling)
and py() never forwarded most of what it stored, so the python XGBoost
reference model trained with mismatched hyperparameters (e.g. eta=0.3
instead of 0.1). Accept both spellings per parameter, with the Vertica
spelling taking precedence, reject unrecognized kwargs instead of
silently ignoring them, and forward learning_rate/gamma/reg_lambda/
subsample/colsample_* from py(). Also drop the incorrect reg_alpha=
weight_reg mapping in model_score() — Vertica's weight_reg is an L2
penalty (reg_lambda), not L1.

See task-1-report.md for full before/after pass counts: the fix is
correct per spec but test_score[...-auc-XGBClassifier] still narrowly
misses its 5e-2 tolerance (gap improved 6.45%->5.30%), and
test_predict[XGBClassifier] newly regresses as a direct, expected
consequence of py() now forwarding its stored defaults. No tolerances
were changed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The test harness hardcoded tree_method="exact" for the python-side XGBoost
reference model. That is a different split-finding algorithm from the one
Vertica runs, and it ignores max_bin entirely -- so the nbins/max_bin value
the harness forwards was dead code.

Vertica's split_proposal_method='global' with nbins is the XGBoost paper's
approximate-greedy split finding with a global proposal, which is xgboost's
tree_method='approx' with max_bin as the bin count. Measured on titanic
(max_ntree=10, max_depth=5, nbins=32, learning_rate=0.1, weight_reg=0):

    exact,  max_bin=32/150/1000 -> roc_auc 0.89691200 (identical)
    approx, max_bin=32          -> roc_auc 0.88798237

against Vertica's 0.851751, taking the gap from -5.04% to -4.08% and inside
the existing 5e-2 tolerance. No tolerance was changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HD4ecxVqvgxNP9No91e2Xp
Six assertions compare a Vertica-trained model against a scikit-learn-trained
one and fail because the two engines are different algorithms, not because
any code is wrong. They are not flaky: both sides are deterministic and
reproduce bit-for-bit across independent retrains.

No tolerance is widened. The sklearn reference's own seed-to-seed spread is
1.89% for prc_auc against a 0.40% tolerance and 16.08% for predict-mean
against 7.00%, so no tolerance value is reachable. Widening is also what hid
the auc/ROC reference defect from 2024-02 until now.

Marked via request.applymarker at runtime rather than in the parametrize
lists, because those lists are shared across model classes -- marking there
would also xfail the DecisionTreeClassifier and XGBClassifier cases, which
pass. strict=True is deliberate: both sides are deterministic, so an
unexpected pass is a real signal.

Also fixes test_prc_curve comparing a 30-point vpy curve against a
full-resolution sklearn one; nbins is now explicit at 10000, matching what
prc_auc_score uses. That is a correctness fix independent of the verdict.

Verified with pinpoint node IDs:
  4 target RF assertions            -> 4 xfailed
  test_predict RF + XGBClassifier   -> 2 xfailed
  test_predict XGBRegressor + DTC   -> 2 passed
  8 neighbouring classification tests -> 8 passed, 0 xfailed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HD4ecxVqvgxNP9No91e2Xp
…ariant

The existing metric tests compare a Vertica-trained model against a
scikit-learn-trained one, which conflates two questions: does VerticaPy
compute the metric correctly, and do the two engines build the same model.
Only the first is VerticaPy's to get right, and only the first has a tight
answer -- the sklearn reference's own seed-to-seed spread exceeds the
tolerances it is measured against.

test_metric_matches_sklearn_on_same_probabilities feeds both implementations
the same probability vector, isolating the metric code. Measured worst
relative difference across five deliberately different Vertica models is
2.7e-05, so rel=1e-3 leaves a 37x margin. That tolerance is kept out of
rel_abs_tol_map on purpose: it is derived from a measurement rather than
fitted to whatever passed.

test_predict_matches_argmax_of_predict_proba asserts an exact invariant of
the prediction path that nothing else covered.

Verified with pinpoint node IDs: 9 passed across RandomForestClassifier,
DecisionTreeClassifier and XGBClassifier.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HD4ecxVqvgxNP9No91e2Xp
… quote

read_pandas() silently stripped literal double quotes from string values:
'say "hi"' round-tripped as 'say hi'. It enclosed each string field in '"',
doubled any embedded quote, then post-processed the file with a blanket
.replace('""', ""). That replace served the all-NULL sentinel, but it also
deleted the doubling that escaped a real quote.

Removing the replace on its own is worse, not better: Vertica does not read a
doubled "" as an escaped quote, so '"say ""hi"""' is a parse error and the
whole row is dropped. Silent character loss becomes silent row loss.

The enclosure itself had to move. '"' is now ordinary data, enclosed by a
control character instead, and the post-processing block is gone. The
all-NULL sentinel writes an empty field directly, which is what NULL ''
matches.

Vertica turns out to require an enclosure character appearing inside an
enclosed field to be written as ESCAPE_AS + enclosure -- a raw or a doubled
one gets the row rejected. Measured against 25.3 with an explicit COPY; the
delimited-data docs state only the first-and-last rule and do not cover this
case. to_csv cannot emit that sequence, because under QUOTE_NONE it only ever
writes the escape character ahead of the escape character, the delimiter or
the record terminator, and it doubles any escape character we pre-insert.

So _pick_enclosure() chooses the first of twelve candidate control characters
that occurs in neither the data nor the column names, and nothing needs
escaping at all.

The enclosure exists for one reason: to stop the flex table that guesses the
column types from retyping a string column, where '007' would be read as
INTEGER. Supplying dtype, or inserting into an existing relation, skips that
guess -- and the data then loads correctly with no enclosure whatsoever
(verified for quotes, commas, surrounding whitespace, '007' and escaped
control characters). read_pandas now falls back to that when the enclosure is
exhausted, and only raises otherwise, naming dtype as the way through.
read_csv and pcsv accept quotechar=None to express it.

Also fixes an independent corruption on the insert path, which declared
ESCAPE AS '\' -- a single backslash -- while to_csv writes escapes as \027.
Its escapes were never decoded and a literal backslash was eaten: 'C:\dir'
loaded as 'C:dir'.

One deliberate behaviour change: an empty string used to round-trip as NULL
via the blanket replace, and now stays an empty string. Nothing pinned the
old behaviour. Note the no-enclosure fallback cannot preserve it -- there an
empty string is indistinguishable from an empty field and loads as NULL.

A string column of '007' still types as INTEGER. That is pre-existing,
confirmed identical on HEAD, and inherent to the flex-table type guess rather
than to the encoding.

tests_new/core: 794 passed, 2 skipped -- the 786 baseline plus the 8 tests
added here, which cover the encoding at byte level offline, the round trip on
both the read_csv and COPY paths asserting row count as well as values, the
enclosure picker, and both escape hatches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ri4NJDUgT2kwPtGfU5Ttt
The previous commit picked the CSV enclosure character at run time, scanning
the string columns for the first of twelve control characters the data did
not contain. That defended against a literal chr(22) in text -- an input that
essentially never occurs -- by making every ingest pay a scan, and it hid the
collision rather than reporting it.

Back to a single fixed ENCLOSED_BY. The collision is now detected when it
actually happens, which costs nothing on the path that already knows: COPY
returns the number of rows it accepted, so insert=True gets the count for
free, and the read_csv path adds one COUNT(*). Fewer rows than the DataFrame
holds raises, naming dtype as the way through.

read_csv creates the relation before the check can run, so it is dropped
again on rejection -- handing back a table quietly short of rows would be the
same silent data loss this whole change is about.

Both checks are gated on the fields actually being enclosed. Supplying dtype
skips the flex-table type guess, which is the only reason to enclose at all,
so nothing can collide and the check would be a wasted round trip; worse, a
rejection there would be a type mismatch that this message would blame on the
enclosure. enclose = not dtype is now the whole rule.

tests_new/core: 793 passed, 2 skipped -- the 786 baseline plus the 7 tests
here, which cover the encoding at byte level offline, the round trip on both
paths, the rejection raising and dropping the table, and dtype carrying a
value that holds the enclosure character.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ri4NJDUgT2kwPtGfU5Ttt
@CLAassistant

CLAassistant commented Aug 31, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants