2025, Sep 23 21:17
Pandas, NumPy и Pydantic: как корректно валидировать NaN и Literal[np.nan]
Почему Pandas возвращает NumPy NaN и из‑за этого Pydantic с Literal[np.nan] падает. Два решения: приведение к float через BeforeValidator и валидатор «any NaN»
Pandas без проблем использует NaN для обозначения пропущенных числовых данных, но когда такой NaN приходит из Series, это скаляр NumPy, а не обычный Python float. Если подать эти значения в модель Pydantic с типом Literal[np.nan], валидация может неожиданно провалиться. Ниже показано, как это воспроизвести, понять причину и аккуратно исправить, не ослабляя схему больше, чем нужно.
Минимальный пример сбоя валидации
Начнём с привычного приёма Pandas: создаём целочисленную Series, делаем reindex, чтобы добавить пропуск, и итерируемся по значениям, проверяя их через модель Pydantic, которая принимает либо положительное целое, либо NaN.
import pandas as pd
import numpy as np
import pydantic
from typing import Union, Literal
# Целочисленная Series; reindex добавляет пропуск в конце
ser_missing = pd.Series([1, 2], dtype=np.int64).reindex([0, 1, 2])
# Модель: «положительное целое» ИЛИ буквальный NaN
class Gauge(pydantic.BaseModel):
    granularity: Union[pydantic.conint(ge=1), Literal[np.nan]]
# Итерируемся по значениям; последний элемент — NaN из Pandas
for item in ser_missing.values:
    # Это вызовет ValidationError на NaN, пришедшем из Pandas
    Gauge(granularity=item)
Вызов модели напрямую с np.nan работает, но проверка NaN, пришедшего из Series, падает. Значит, объект, который отдала Pandas, не совпадает с объектом, с которым сопоставляется Literal[np.nan].
Что на самом деле происходит
Pandas возвращает для пропуска скаляр NumPy, конкретно numpy.float64('nan'). В Pydantic Literal[np.nan] сопоставляется с питоновским NaN (float('nan')), а не с NumPy NaN. По смыслу оба — NaN, но для сопоставления Literal это не один и тот же объект, поэтому проверка литерала отвергает скаляр NumPy.
Два точных способа это исправить
Первая стратегия — привести плавающие скаляры NumPy к встроенным Python float до того, как сработает Literal[np.nan]. Тогда numpy.float64('nan') превратится в float('nan'), и литерал его примет.
from typing import Union, Literal
from typing_extensions import Annotated
from pydantic import BaseModel, PositiveInt, BeforeValidator
import numpy as np
# Приводим плавающие скаляры NumPy к питоновским float
def to_builtin_float(value):
    if isinstance(value, np.floating):
        return float(value)
    return value
OnlyNaN = Annotated[Literal[np.nan], BeforeValidator(to_builtin_float)]
class GaugeFixed(BaseModel):
    granularity: Union[PositiveInt, OnlyNaN]
# Использование со значениями из Pandas
for val in ser_missing.values:
    GaugeFixed(granularity=val)
Вторая стратегия — объявить тип «любой NaN», который через валидатор примет и Python float NaN, и NumPy NaN. Так мы вовсе не используем Literal и проверяем именно семантическое свойство «это NaN».
from typing import Union
from typing_extensions import Annotated
from pydantic import BaseModel, PositiveInt, AfterValidator
import numpy as np
import math
# Принимает любой NaN (Python или NumPy) и нормализует к float('nan')
def require_nan(value):
    if isinstance(value, (float, np.floating)) and math.isnan(float(value)):
        return float('nan')
    raise ValueError('not NaN')
AnyNaN = Annotated[float, AfterValidator(require_nan)]
class GaugeStrict(BaseModel):
    granularity: Union[PositiveInt, AnyNaN]
# Использование со значениями из Pandas
for val in ser_missing.values:
    GaugeStrict(granularity=val)
Оба подхода бьют по первопричине: несоответствие скалярного типа NumPy тому, с чем сравнивается Literal[np.nan]. Если приведение в вашей среде всё же падает на NumPy NaN, предпочтительнее вариант с «any NaN», поскольку он проверяет семантическое свойство напрямую.
Почему эта тонкость важна
Конвейеры данных часто передают значения между Pandas, NumPy и Pydantic. Небольшие различия типов — например, numpy.float64 против float — могут тихо ломать строгие схемы, завязанные на Literal или строгие объединения. Если модель должна трактовать NaN как полноценный маркер «пропущено» наряду с ограниченными целыми, нужен подход, который принимает данные, возвращаемые Pandas, не открывая дверь для произвольных float.
Выводы
При валидации результатов Pandas помните: пропущенные числовые значения приходят как скаляры NumPy. Literal[np.nan] сопоставляется с питоновским NaN и отвергнет NumPy-эквивалент. Чтобы сохранить точность модели, либо нормализуйте скаляры NumPy к Python float до проверки литерала, либо используйте валидатор «any NaN», который проверяет само свойство «быть NaN», а целые остаются строго ограниченными. Так модели ведут себя предсказуемо, избегают излишней разрешительности и согласуются с тем, как Pandas представляет пропуски.
Статья основана на вопросе на StackOverflow от MikeFenton и ответе от Dmitry543.