Skip to content

Commit c0ed37d

Browse files
Merge pull request #846 from DashAISoftware/feat/date-column-type
Enable Date type
2 parents 686edbb + aee6fa1 commit c0ed37d

14 files changed

Lines changed: 889 additions & 31 deletions

File tree

DashAI/back/api/api_v1/endpoints/datasets.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2157,13 +2157,16 @@ async def validate_type_changes(
21572157
filepath_or_buffer=tmp_file_path, params=parsed_params, n_rows=1000
21582158
)
21592159

2160-
all_valid, errors = validate_multiple_type_changes(
2160+
all_valid, errors, resolved_dtypes = validate_multiple_type_changes(
21612161
sample_df, parsed_type_changes
21622162
)
21632163

21642164
return {
21652165
"valid": all_valid,
21662166
"errors": errors,
2167+
# A Date column's strptime format is detected from the data, so
2168+
# the frontend learns it here rather than choosing it.
2169+
"resolved_dtypes": resolved_dtypes,
21672170
}
21682171

21692172
finally:

DashAI/back/dataloaders/classes/dashai_dataset.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -817,8 +817,23 @@ def transform_dataset_with_schema(
817817
# we are saving them as strings to preserve the original format.
818818
# Can modify classes in value_types.py
819819
# if want to use PyArrow date, time or timestamp types.
820+
#
821+
# Two dict shapes reach this function. The one built by
822+
# type inference and by get_columns_spec carries the
823+
# strptime format in "dtype"; the one a column emits
824+
# through to_string() carries it in "format" and leaves
825+
# "dtype" as the arrow type. Reading only "dtype" turned
826+
# the second shape's format into the literal "string".
827+
_format = info.get("format") or dtype
828+
if not _format:
829+
raise ValueError(
830+
f"Column '{column_name}' is typed as {_type} but "
831+
"carries no format. A date, time or timestamp "
832+
"column is stored as text plus a strptime format, "
833+
"so the format has to be resolved before saving."
834+
)
820835
dashai_types[column_name] = arrow_to_dashai_types(
821-
arrow_type=_type, format=dtype
836+
arrow_type=_type, format=_format
822837
)
823838
pa_type = to_arrow_types("string")
824839
dai_table[column_name] = table.column(column_name)

DashAI/back/types/date_utils.py

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
"""Helpers for reading DashAI ``Date`` columns.
2+
3+
A ``Date`` column is stored as text plus a strptime format, so anything that
4+
needs real datetimes has to parse first. This module is the only place that
5+
does, because text ordering agrees with chronological ordering for ISO layouts
6+
alone: ``"31-01-2020" < "01-02-2020"`` holds as text and fails as dates.
7+
"""
8+
9+
from typing import TYPE_CHECKING, Any, Final, List, Optional, Union
10+
11+
if TYPE_CHECKING:
12+
import pandas as pd
13+
14+
# The strptime format a Date column falls back to when nothing better is known.
15+
DEFAULT_DATE_FORMAT: Final[str] = "%Y-%m-%d"
16+
17+
# Layouts ``pandas.guess_datetime_format`` does not produce. Two digit years
18+
# are the ones that matter: it returns None for "1/31/20". They are grouped by
19+
# component ordering so a day first hint can put the day first candidates ahead
20+
# of the month first ones, which is the only thing separating "01/02/20" from
21+
# itself read the other way round.
22+
_DAY_FIRST_EXTRA: Final[List[str]] = ["%d/%m/%y", "%d-%m-%y", "%d.%m.%y"]
23+
_MONTH_FIRST_EXTRA: Final[List[str]] = ["%m/%d/%y", "%m-%d-%y"]
24+
_YEAR_FIRST_EXTRA: Final[List[str]] = ["%y-%m-%d", "%y/%m/%d"]
25+
26+
EXTRA_DATE_FORMATS: Final[List[str]] = (
27+
_MONTH_FIRST_EXTRA + _DAY_FIRST_EXTRA + _YEAR_FIRST_EXTRA
28+
)
29+
30+
# How many distinct values are handed to the format guesser. A single value can
31+
# be ambiguous ("01/02/2020"); a handful rarely all are.
32+
_SAMPLE_SIZE: Final[int] = 5
33+
34+
35+
def _as_clean_text(values: Any) -> "pd.Series":
36+
"""Normalise a column to stripped text with blanks treated as missing.
37+
38+
Parameters
39+
----------
40+
values : Any
41+
The column values. Anything ``pandas.Series`` accepts.
42+
43+
Returns
44+
-------
45+
pandas.Series
46+
A string dtype series where empty and whitespace only entries are NA.
47+
"""
48+
import pandas as pd # local import
49+
50+
series = values if isinstance(values, pd.Series) else pd.Series(list(values))
51+
text = series.reset_index(drop=True).astype("string").str.strip()
52+
return text.mask(text == "")
53+
54+
55+
def _names_a_full_date(date_format: str) -> bool:
56+
"""Check that a strptime format pins down a specific day.
57+
58+
A format naming only a year and a month, such as ``"%Y-%m"``, parses
59+
cleanly but silently invents a day of the month. Treating such a column as
60+
a Date would hand every consumer a date the data never stated, so those
61+
columns are better left as text.
62+
63+
Parameters
64+
----------
65+
date_format : str
66+
The strptime format to check.
67+
68+
Returns
69+
-------
70+
bool
71+
``True`` when the format names a day, a month and a year.
72+
"""
73+
has_day = "%d" in date_format
74+
has_month = any(token in date_format for token in ("%m", "%b", "%B"))
75+
has_year = "%Y" in date_format or "%y" in date_format
76+
return has_day and has_month and has_year
77+
78+
79+
def parse_date_column(values: Any, format: str = DEFAULT_DATE_FORMAT) -> "pd.Series":
80+
"""Parse a column of date strings into datetimes.
81+
82+
Parameters
83+
----------
84+
values : Any
85+
The column values to parse.
86+
format : str, optional
87+
A strptime format such as ``"%d/%m/%Y"``. Defaults to
88+
``DEFAULT_DATE_FORMAT``.
89+
90+
Returns
91+
-------
92+
pandas.Series
93+
The parsed datetimes. Missing and blank entries stay ``NaT``.
94+
95+
Raises
96+
------
97+
ValueError
98+
If any non-missing value does not match ``format``. The message names
99+
up to three of the offending values.
100+
"""
101+
import pandas as pd # local import
102+
103+
text = _as_clean_text(values)
104+
parsed = pd.to_datetime(text, format=format, errors="coerce")
105+
106+
failed = text.notna() & parsed.isna()
107+
if failed.any():
108+
sample = ", ".join(repr(value) for value in text[failed].unique()[:3])
109+
raise ValueError(
110+
f"{int(failed.sum())} value(s) do not match the date format "
111+
f"'{format}': {sample}"
112+
)
113+
114+
return parsed
115+
116+
117+
def detect_date_format(values: Any, hint: Optional[str] = None) -> Optional[str]:
118+
"""Find a strptime format that reads every value in a column.
119+
120+
Candidates come from ``pandas.guess_datetime_format`` applied to a sample
121+
of the values under both day first and month first readings, followed by
122+
``EXTRA_DATE_FORMATS``. Each candidate is then checked against the whole
123+
column, which is what catches a guess that only fits the first few rows.
124+
125+
Parameters
126+
----------
127+
values : Any
128+
The column values to inspect.
129+
hint : str, optional
130+
The ptype label for this column. ``"date-eu"`` means day first, which
131+
is the only thing that can disambiguate a value like ``"01/02/2020"``.
132+
133+
Returns
134+
-------
135+
str or None
136+
A strptime format that parses every non-missing value, or ``None``
137+
when no candidate does.
138+
"""
139+
import warnings # local import
140+
141+
from pandas.tseries.api import guess_datetime_format # local import
142+
143+
text = _as_clean_text(values).dropna()
144+
if text.empty:
145+
return None
146+
147+
day_first = hint == "date-eu"
148+
sample = text.drop_duplicates().head(_SAMPLE_SIZE)
149+
150+
candidates: List[str] = []
151+
# Trying both readings is the point, so pandas warning that a value looks
152+
# day first while asked month first says nothing new. Left unsuppressed it
153+
# fires for most columns of every upload.
154+
with warnings.catch_warnings():
155+
warnings.simplefilter("ignore", UserWarning)
156+
for first in (day_first, not day_first):
157+
for value in sample:
158+
try:
159+
guess = guess_datetime_format(value, dayfirst=first)
160+
except (ValueError, TypeError):
161+
guess = None
162+
if guess and guess not in candidates:
163+
candidates.append(guess)
164+
165+
extras = (
166+
_DAY_FIRST_EXTRA + _MONTH_FIRST_EXTRA + _YEAR_FIRST_EXTRA
167+
if day_first
168+
else EXTRA_DATE_FORMATS
169+
)
170+
candidates.extend(fmt for fmt in extras if fmt not in candidates)
171+
172+
for candidate in candidates:
173+
if not _names_a_full_date(candidate):
174+
continue
175+
try:
176+
parse_date_column(text, candidate)
177+
except ValueError:
178+
continue
179+
return candidate
180+
181+
return None
182+
183+
184+
def infer_frequency(dates: Any) -> Union[str, "pd.Timedelta", None]:
185+
"""Describe the calendar spacing of a parsed date series.
186+
187+
Parameters
188+
----------
189+
dates : Any
190+
Already parsed datetimes, in ascending order.
191+
192+
Returns
193+
-------
194+
str or pandas.Timedelta or None
195+
A pandas frequency alias when the dates sit on a regular grid, the
196+
most common gap between consecutive dates when they do not, and
197+
``None`` when there is too little data to say anything.
198+
"""
199+
import pandas as pd # local import
200+
201+
parsed = pd.to_datetime(pd.Series(list(dates)), errors="coerce").dropna()
202+
if len(parsed) < 3:
203+
return None
204+
205+
index = pd.DatetimeIndex(parsed)
206+
try:
207+
alias = pd.infer_freq(index)
208+
except ValueError:
209+
alias = None
210+
if alias is not None:
211+
return alias
212+
213+
gaps = index.to_series().diff().dropna()
214+
modes = gaps.mode()
215+
return modes.iloc[0] if not modes.empty else None

DashAI/back/types/inf/inference_methods.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import pandas as pd
22

33
import DashAI.back.types.inf.ptype.Machine as Machine
4+
from DashAI.back.types.date_utils import detect_date_format
45
from DashAI.back.types.inf.Inference import InferenceMethod
56
from DashAI.back.types.inf.ptype.Machines import MACHINES, Machines
67
from DashAI.back.types.inf.ptype.PtypeCat import PtypeCat
@@ -96,6 +97,21 @@ def infer_types(self, data) -> dict:
9697
else:
9798
dashai_info["encoder"] = "one_hot"
9899

100+
# A Date column stores a strptime format, and the ptype label does
101+
# not name one: "01-01-2020" and "01/02/2020" both come back as
102+
# "date-eu". Read the layout off the values, and stay Text when
103+
# nothing fits, which is what happened for every date before this.
104+
elif dashai_info["type"] == "Date":
105+
detected = detect_date_format(data[col_name].dropna(), hint=ptype_type)
106+
if detected is None:
107+
dashai_info = {
108+
"type": "Text",
109+
"dtype": "string",
110+
"encoding": "utf-8",
111+
}
112+
else:
113+
dashai_info["dtype"] = detected
114+
99115
reason = getattr(col_object, "inference_reason", None)
100116
if reason is not None:
101117
reason = {**reason}
@@ -171,7 +187,10 @@ def infer_types(self, data):
171187
elif pd.api.types.is_bool_dtype(dtype):
172188
inferred_types[col] = PTYPE_TO_DASHAI["boolean"]
173189
elif pd.api.types.is_datetime64_any_dtype(dtype):
174-
inferred_types[col] = PTYPE_TO_DASHAI["date-iso-8601"]
190+
# This method does no format detection, so it cannot produce a
191+
# usable Date. "string" is the same dict the date entry held
192+
# before Date was enabled, so behaviour here is unchanged.
193+
inferred_types[col] = PTYPE_TO_DASHAI["string"]
175194
else:
176195
inferred_types[col] = PTYPE_TO_DASHAI["string"]
177196
return inferred_types

0 commit comments

Comments
 (0)