Files
my-v0-server/app/datafiles/columns.py
T

262 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""列名清理和类型推断,输出 Arrow 列
单元格的类型规则在 cells.py;这里按列汇总:一列的非空值必须全部符合同一类型才转换,否则保留文本,
宁可让模型在 SQL 里 TRY_CAST,也不悄悄丢掉值。各列互不相关,用线程并行推断(Arrow 的计算不占 GIL)
"""
import os
import re
from collections.abc import Sequence
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from typing import Any
import numpy as np
import pyarrow as pa
import pyarrow.compute as pc
from app.datafiles.base import Reporter, brief, no_report, where
from app.datafiles.cells import (
BOOL,
DATE,
DATETIME,
EXACT_FLOAT_INT,
FAST_RULES,
FLOAT,
INT,
KINDS,
MICROS_PER_DAY,
NULL,
NULL_TEXT,
PERCENT,
TEXT,
Classified,
as_text,
classify_piece,
)
from app.datafiles.grid import Column
from app.schemas.data_file import DataColumn, ParseWarning
NUMERIC_KINDS = (INT, FLOAT, PERCENT)
DATE_KINDS = (DATE, DATETIME)
# 同时推断的列数
INFER_THREADS = min(8, os.cpu_count() or 1)
@dataclass
class TypedColumns:
columns: list[DataColumn]
table: pa.Table
# 列名 → 写进列概况的说明,例如保留为文本的原因
notes: dict[str, str]
def clean_names(
raw: Sequence[Any], sheet: str | None
) -> tuple[list[str], ParseWarning | None]:
"""去掉空白和换行;空列名按位置命名,重复列名加后缀"""
names, changes, seen = [], [], set()
for position, value in enumerate(raw, start=1):
name = re.sub(r"\s*[\r\n]+\s*", " ", as_text(value)).strip()
if not name:
name = f"列{position}"
changes.append(f"第 {position} 列没有列名,命名为「{name}」")
if name in seen:
suffix = 2
while f"{name}_{suffix}" in seen:
suffix += 1
changes.append(f"「{name}」重名,第 {position} 列改为「{name}_{suffix}」")
name = f"{name}_{suffix}"
seen.add(name)
names.append(name)
if not changes:
return names, None
return names, ParseWarning(
code="column_renamed", message=f"{where(sheet)}{brief(changes, ';')}"
)
def build_columns(
names: list[str],
columns: list[Column],
sheet: str | None,
report: Reporter = no_report,
) -> tuple[TypedColumns, ParseWarning | None]:
"""按列推断类型;有列因少数无法解析的值保留为文本时,返回一条 kept_as_text 警告
columns 会被清空:每列推断完就释放它的原始单元格,大表不必同时留着原始和转换后的两份
"""
described, arrays, notes = [], [], {}
with ThreadPoolExecutor(INFER_THREADS) as pool:
futures = [
pool.submit(infer_column, name, column)
for name, column in zip(names, columns)
]
columns.clear()
for number, (name, future) in enumerate(zip(names, futures), start=1):
column, array, note = future.result()
report(number / len(names))
described.append(column)
arrays.append(array)
if note:
notes[name] = note
warning = None
if notes:
listed = "、".join(f"「{name}」" for name in notes)
warning = ParseWarning(
code="kept_as_text",
message=f"{where(sheet)}{listed}有无法解析的值,整列按文本处理",
)
return TypedColumns(
described, pa.Table.from_arrays(arrays, names=names), notes
), warning
def arrow_columns(table: pa.Table) -> tuple[TypedColumns, ParseWarning | None]:
"""Parquet 自带类型,只映射到接口类型;列表、嵌套结构等存成 JSON 字符串"""
names, warning = clean_names(table.column_names, None)
columns, arrays = [], []
for name, array in zip(names, table.columns):
kind = _arrow_kind(array.type)
if kind is None:
values = array.to_pylist()
array = pa.array(
[None if v is None else as_text(v) for v in values], pa.string()
)
kind = "text"
elif kind == "text" and pa.types.is_dictionary(array.type):
array = array.cast(pa.string())
columns.append(DataColumn(name=name, type=kind))
arrays.append(array)
return TypedColumns(columns, pa.Table.from_arrays(arrays, names=names), {}), warning
def _arrow_kind(arrow_type: pa.DataType) -> str | None:
types = pa.types
if (
types.is_integer(arrow_type)
or types.is_floating(arrow_type)
or types.is_decimal(arrow_type)
):
return "number"
if types.is_boolean(arrow_type):
return "boolean"
if types.is_date(arrow_type):
return "date"
if types.is_timestamp(arrow_type):
return "datetime"
if (
types.is_string(arrow_type)
or types.is_large_string(arrow_type)
or types.is_string_view(arrow_type)
):
return "text"
if types.is_dictionary(arrow_type) and _arrow_kind(arrow_type.value_type) == "text":
return "text"
return None
def infer_column(
name: str, column: Column
) -> tuple[DataColumn, pa.ChunkedArray, str | None]:
order = list(FAST_RULES)
parts = [classify_piece(piece, order) for piece in column.pieces]
counts = sum((part.count() for part in parts), np.zeros(len(KINDS), np.int64))
present = {kind for kind in range(len(KINDS)) if counts[kind] and kind != NULL}
def build(make, arrow_type: pa.DataType) -> pa.ChunkedArray:
return pa.chunked_array([make(part) for part in parts], arrow_type)
if present and present <= {BOOL}:
array = build(
lambda part: _pick(part, "flags", (BOOL,), pa.bool_()), pa.bool_()
)
return DataColumn(name=name, type="boolean"), array, None
if present and present <= {INT, FLOAT}:
if all(_integral(part) for part in parts):
array = build(lambda part: _numbers(part, np.int64), pa.int64())
else:
array = build(lambda part: _numbers(part, np.float64), pa.float64())
return DataColumn(name=name, type="number"), array, None
if present == {PERCENT}:
array = build(
lambda part: _pick(part, "reals", (PERCENT,), pa.float64()), pa.float64()
)
return DataColumn(name=name, type="number", format="percent"), array, None
if present and present <= {DATE}:
array = build(_dates, pa.date32())
return DataColumn(name=name, type="date"), array, None
if present and present <= set(DATE_KINDS):
array = build(
lambda part: _pick(part, "moments", DATE_KINDS, pa.timestamp("us")),
pa.timestamp("us"),
)
return DataColumn(name=name, type="datetime"), array, None
array = build(_texts, pa.string())
return DataColumn(name=name, type="text"), array, _text_note(counts, parts)
def _pick(
part: Classified, field: str, kinds: tuple[int, ...], arrow_type: pa.DataType
) -> pa.Array:
"""这段里属于 kinds 的值,其余位置为空"""
keep = np.isin(part.kinds, kinds)
return pa.array(part.values(field), arrow_type, mask=~keep)
def _integral(part: Classified) -> bool:
"""小数都是整数值并且在 float 能精确表示的范围内(Excel 的整数读出来是 100.0)"""
reals = part.values("reals")[part.kinds == FLOAT]
return bool(np.all((np.floor(reals) == reals) & (np.abs(reals) < EXACT_FLOAT_INT)))
def _numbers(part: Classified, dtype) -> pa.Array:
is_int, is_float = part.kinds == INT, part.kinds == FLOAT
# 其他位置可能是 NaN(float 段里的空值),先清零再转换
reals = np.where(is_float, part.values("reals"), 0.0)
values = np.where(is_int, part.values("ints").astype(dtype), reals.astype(dtype))
keep = is_int | is_float
return pa.array(values, pa.from_numpy_dtype(dtype), mask=~keep)
def _dates(part: Classified) -> pa.Array:
days = (part.values("moments") // MICROS_PER_DAY).astype(np.int32)
return pa.array(days, pa.date32(), mask=part.kinds != DATE)
def _texts(part: Classified) -> pa.Array:
"""保留为文本:字符串是去掉空白后的原文,其他类型按 as_text 转换;空值记号仍为空"""
keep = part.kinds != NULL
if pa.types.is_string(part.piece.type):
return pc.if_else(pa.array(keep), part.piece, NULL_TEXT)
return pa.array(
[
as_text(value) if kept else None
for value, kept in zip(part.piece.to_pylist(), keep)
],
pa.string(),
)
def _text_note(counts: np.ndarray, parts: list[Classified]) -> str | None:
"""大部分值是数值或日期、少数无法解析时,说明原因,模型可以在 SQL 里用 TRY_CAST"""
failed = int(counts[TEXT])
numeric = int(sum(counts[kind] for kind in NUMERIC_KINDS))
dates = int(sum(counts[kind] for kind in DATE_KINDS))
if not failed or max(numeric, dates) < failed:
return None
examples: list[str] = []
for part in parts:
failures = _texts(part).filter(pa.array(part.kinds == TEXT))
for text in pc.unique(failures).to_pylist():
if text not in examples:
examples.append(text)
if len(examples) == 2:
break
if len(examples) == 2:
break
shown = "、".join(f"「{example}」" for example in examples)
label = "数值" if numeric >= dates else "日期"
return f"看起来是{label},有 {failed} 个值无法解析,例如{shown}"