Mathew K Analytics

Lesson 18 · Pandas Full Course

Fix Wrong Data Types in Pandas: astype, to_numeric and to_datetime | Pandas Course #18

In this lesson you'll be able to: Read a table's types and spot the ones that are wrong Turn text into real dates and numbers, safely Store whole numbers…

What you'll learn

Datasets used in this lesson

Save these next to the notebook. In Google Colab, upload them with the 📁 icon on the left first.

📓 Full notebook

Download .ipynb

Pandas 18: Changing Data Types and Converting Units#

In this lesson you'll be able to:

  • Read a table's types and spot the ones that are wrong
  • Turn text into real dates and numbers, safely
  • Store whole numbers with gaps, and save memory with categories
  • Convert units, like pounds to kilograms and mpg to litres Data: a year of online retail sales and 398 classic cars

Warm-up: the shop data, minus its copies#

Last lesson we removed 5,268 duplicate rows. Let's do it again.

import pandas as pd

retail = pd.read_csv("data/pandas/online_retail.csv.gz")
retail = retail.drop_duplicates(ignore_index=True)
print(retail.shape)
(536641, 8)
retail.dtypes
InvoiceNo          str
StockCode          str
Description        str
Quantity         int64
InvoiceDate        str
UnitPrice      float64
CustomerID     float64
Country            str
dtype: object

Check the types before you calculate#

The wrong type gives wrong answers, or no answer at all.

Quick check: a column holds the text "6" and "6". What does its sum give?

digits = retail["Quantity"].head(2).astype(str)
print(digits.sum())
print(digits.astype(int).sum())
66
12
retail.info()
<class 'pandas.DataFrame'>
RangeIndex: 536641 entries, 0 to 536640
Data columns (total 8 columns):
 #   Column       Non-Null Count   Dtype  
---  ------       --------------   -----  
 0   InvoiceNo    536641 non-null  str    
 1   StockCode    536641 non-null  str    
 2   Description  535187 non-null  str    
 3   Quantity     536641 non-null  int64  
 4   InvoiceDate  536641 non-null  str    
 5   UnitPrice    536641 non-null  float64
 6   CustomerID   401604 non-null  float64
 7   Country      536641 non-null  str    
dtypes: float64(2), int64(1), str(5)
memory usage: 66.9 MB
print(retail.select_dtypes(include="number").columns.tolist())
print(retail.select_dtypes(include="str").columns.tolist())
['Quantity', 'UnitPrice', 'CustomerID']
['InvoiceNo', 'StockCode', 'Description', 'InvoiceDate', 'Country']

Common mistake: treating a text column as dates.

retail["InvoiceDate"].dt.month
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[7], line 1
----> 1 retail["InvoiceDate"].dt.month

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\generic.py:6194, in NDFrame.__getattr__(self, name)
   6187 if (
   6188     name not in self._internal_names_set
   6189     and name not in self._metadata
   6190     and name not in self._accessors
   6191     and self._info_axis._can_hold_identifiers_and_holds_name(name)
   6192 ):
   6193     return self[name]
-> 6194 return object.__getattribute__(self, name)

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\accessor.py:230, in Accessor.__get__(self, obj, cls)
    227 if obj is None:
    228     # we're accessing the attribute of the class, i.e., Dataset.geo
    229     return self._accessor
--> 230 return self._accessor(obj)

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\indexes\accessors.py:698, in CombinedDatetimelikeProperties.__new__(cls, data)
    695 elif isinstance(data.dtype, PeriodDtype):
    696     return PeriodProperties(data, orig)
--> 698 raise AttributeError("Can only use .dt accessor with datetimelike values")

AttributeError: Can only use .dt accessor with datetimelike values
retail["InvoiceDate"] = pd.to_datetime(retail["InvoiceDate"],
                                       format="%m/%d/%Y %H:%M")
print(retail["InvoiceDate"].dtype)
print(retail["InvoiceDate"].min(), "to", retail["InvoiceDate"].max())
datetime64[us]
2010-12-01 08:26:00 to 2011-12-09 12:50:00
retail["InvoiceDate"].dt.month_name().value_counts().head(3)
InvoiceDate
November    83343
December    67227
October     59969
Name: count, dtype: int64

Your turn: which day of the week has the most order lines?

retail["InvoiceDate"].dt.day_name().value_counts()
InvoiceDate
Thursday     103056
Tuesday      101064
Monday        94435
Wednesday     93715
Friday        81565
Sunday        62806
Name: count, dtype: int64

Turn text into numbers safely#

pd.to_numeric converts text, and errors="coerce" marks what won't fit.

Common mistake: assuming every value in a column is a number.

pd.to_numeric(retail["InvoiceNo"])
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
File pandas/_libs/lib.pyx:2478, in pandas._libs.lib.maybe_convert_numeric()

ValueError: Unable to parse string "C536379"

During handling of the above exception, another exception occurred:

ValueError                                Traceback (most recent call last)
Cell In[11], line 1
----> 1 pd.to_numeric(retail["InvoiceNo"])

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\tools\numeric.py:235, in to_numeric(arg, errors, downcast, dtype_backend)
    233     values = ensure_object(values)
    234     coerce_numeric = errors != "raise"
--> 235     values, new_mask = lib.maybe_convert_numeric(  # type: ignore[call-overload]
    236         values,
    237         set(),
    238         coerce_numeric=coerce_numeric,
    239         convert_to_masked_nullable=dtype_backend is not lib.no_default
    240         or (
    241             isinstance(values_dtype, StringDtype)
    242             and values_dtype.na_value is libmissing.NA
    243         ),
    244     )
    246 if new_mask is not None:
    247     # Remove unnecessary values, is expected later anyway and enables
    248     # downcasting
    249     values = values[~new_mask]

File pandas/_libs/lib.pyx:2521, in pandas._libs.lib.maybe_convert_numeric()

ValueError: Unable to parse string "C536379" at position 141
inv_num = pd.to_numeric(retail["InvoiceNo"], errors="coerce")
bad = inv_num.isna()
print(bad.sum(), "could not be converted")
retail.loc[bad, "InvoiceNo"].str[0].value_counts()
9254 could not be converted
InvoiceNo
C    9251
A       3
Name: count, dtype: int64
retail["is_cancelled"] = retail["InvoiceNo"].str.startswith("C")
print(retail["is_cancelled"].dtype)
retail.groupby("is_cancelled")["Quantity"].sum()
bool
is_cancelled
False    5438062
True     -275560
Name: Quantity, dtype: int64
survey = pd.read_csv("data/pandas/salary_survey.csv")
amounts = survey.loc[[752, 9284, 1001, 11734], "currency_other"]
print(amounts.tolist())
print(pd.to_numeric(amounts, errors="coerce").tolist())
fixed = amounts.str.replace(r"[$,]", "", regex=True)
print(pd.to_numeric(fixed, errors="coerce").tolist())
['$76,302.34', '55,000', '0', '6000 in stock grants annually']
[nan, nan, 0.0, nan]
[76302.34, 55000.0, 0.0, nan]

Your turn: show the 3 invoices that start with A. What are they?

is_a = retail["InvoiceNo"].str.startswith("A")
retail.loc[is_a, ["InvoiceNo", "Description", "UnitPrice"]]
InvoiceNo Description UnitPrice
297645 A563185 Adjust bad debt 11062.06
297646 A563186 Adjust bad debt -11062.06
297647 A563187 Adjust bad debt -11062.06

Quick check: should StockCode become a number too?

codes = pd.to_numeric(retail["StockCode"], errors="coerce")
print(codes.isna().sum(), "codes contain letters")
retail.loc[codes.isna(), "StockCode"].unique()[:6]
54487 codes contain letters
<ArrowStringArray>
['85123A', '84406B', '84029G', '84029E', 'POST', '82494L']
Length: 6, dtype: str

Store whole numbers that have gaps#

A single gap turns a whole-number column into decimals. Int64 fixes that.

ids = retail["CustomerID"]
print(ids.dtype, "with", ids.isna().sum(), "missing")
print(ids.astype(str).head(3).tolist())
float64 with 135037 missing
['17850.0', '17850.0', '17850.0']
retail["CustomerID"] = ids.astype("Int64")
print(retail["CustomerID"].dtype)
print(retail["CustomerID"].astype("string").head(3).tolist())
print(retail["CustomerID"].isna().sum(), "still missing")
Int64
['17850', '17850', '17850']
135037 still missing

Your turn: convert UnitPrice from pounds to whole pence.

pence = (retail["UnitPrice"] * 100).round().astype(int)
print(pence.dtype)
print(pence.head(3).tolist())
int64
[255, 339, 275]

Save memory with categories and smaller numbers#

Repeated labels and oversized number types waste memory.

before = retail.memory_usage(deep=True)
print(round(before.sum() / 1e6, 1), "MB in total")
(before / 1e6).round(1).sort_values(ascending=False).head(3)
63.1 MB in total
Description    18.6
Country        11.5
InvoiceNo       7.6
dtype: float64
print(retail["Country"].nunique(), "countries")
retail["Country"] = retail["Country"].astype("category")
mb = retail["Country"].memory_usage(deep=True) / 1e6
print(round(mb, 1), "MB as a category")
38 countries
0.5 MB as a category
print(retail["Country"].cat.categories[:4].tolist())
print(retail["Country"].cat.codes.head(3).tolist())
['Australia', 'Austria', 'Bahrain', 'Belgium']
[36, 36, 36]
slim = pd.read_csv("data/pandas/online_retail.csv.gz",
                   usecols=["InvoiceNo", "Quantity", "Country"],
                   dtype={"Country": "category"})
print(slim.dtypes)
print(round(slim.memory_usage(deep=True).sum() / 1e6, 1), "MB")
InvoiceNo         str
Quantity        int64
Country      category
dtype: object
12.5 MB

Quick check: can the tiny int8 type hold every quantity?

print(retail["Quantity"].max(), "largest quantity")
print(retail["Quantity"].astype("int8").max(), "after int8")
80995 largest quantity
127 after int8
qty = pd.to_numeric(retail["Quantity"], downcast="integer")
print(qty.dtype, qty.max())
int32 80995
retail = retail.astype({"StockCode": "category",
                        "Description": "category",
                        "InvoiceNo": "category"})
retail["Quantity"] = qty
after = retail.memory_usage(deep=True).sum() / 1e6
print(round(before.sum() / 1e6, 1), "MB ->", round(after, 1), "MB")
63.1 MB -> 20.4 MB

Convert units with simple arithmetic#

Multiply by a conversion factor, and name the new column after its unit.

cars = pd.read_csv("data/pandas/mpg.csv")
print(cars.shape)
cars[["name", "mpg", "weight", "displacement"]].head(3)
(398, 9)
name mpg weight displacement
0 chevrolet chevelle malibu 18.0 3504 307.0
1 buick skylark 320 15.0 3693 350.0
2 plymouth satellite 18.0 3436 318.0
KG_PER_LB = 0.453592
LITRES_PER_CUBIC_INCH = 0.0163871
cars["weight_kg"] = (cars["weight"] * KG_PER_LB).round()
cars["engine_l"] = (cars["displacement"]
                    * LITRES_PER_CUBIC_INCH).round(1)
cars[["weight", "weight_kg", "displacement", "engine_l"]].head(3)
weight weight_kg displacement engine_l
0 3504 1589.0 307.0 5.0
1 3693 1675.0 350.0 5.7
2 3436 1559.0 318.0 5.2
cars["l_per_100km"] = (235.215 / cars["mpg"]).round(1)
cars[["name", "mpg", "l_per_100km"]].head(3)
name mpg l_per_100km
0 chevrolet chevelle malibu 18.0 13.1
1 buick skylark 320 15.0 15.7
2 plymouth satellite 18.0 13.1

Quick check: does a higher number mean a better car in both units?

cars.nsmallest(3, "l_per_100km")[["name", "mpg", "l_per_100km"]]
name mpg l_per_100km
322 mazda glc 46.6 5.0
325 vw rabbit c (diesel) 44.3 5.3
329 honda civic 1500 gl 44.6 5.3
cars["year"] = cars["model_year"] + 1900
cars["origin"] = cars["origin"].astype("category")
cars[["model_year", "year", "origin"]].dtypes
model_year       int64
year             int64
origin        category
dtype: object
auto = pd.read_csv("data/pandas/mpg.csv").convert_dtypes()
auto.dtypes.head(5)
mpg             Float64
cylinders         Int64
displacement    Float64
horsepower        Int64
weight            Int64
dtype: object
cars.groupby("cylinders")["engine_l"].median()
cylinders
3    1.10
4    1.70
5    2.10
6    3.75
8    5.70
Name: engine_l, dtype: float64

Common mistake: forcing a column with gaps into plain int.

cars["horsepower"].astype(int)
---------------------------------------------------------------------------
IntCastingNaNError                        Traceback (most recent call last)
Cell In[34], line 1
----> 1 cars["horsepower"].astype(int)

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\generic.py:6529, in NDFrame.astype(self, dtype, copy, errors)
   6525     results = [ser.astype(dtype, errors=errors) for _, ser in self.items()]
   6527 else:
   6528     # else, only a single dtype is given
-> 6529     new_data = self._mgr.astype(dtype=dtype, errors=errors)
   6530     res = self._constructor_from_mgr(new_data, axes=new_data.axes)
   6531     return res.__finalize__(self, method="astype")

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\internals\managers.py:627, in BaseBlockManager.astype(self, dtype, errors)
    626 def astype(self, dtype, errors: str = "raise") -> Self:
--> 627     return self.apply("astype", dtype=dtype, errors=errors)

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\internals\managers.py:445, in BaseBlockManager.apply(self, f, align_keys, **kwargs)
    443         applied = b.apply(f, **kwargs)
    444     else:
--> 445         applied = getattr(b, f)(**kwargs)
    446     result_blocks = extend_blocks(applied, result_blocks)
    448 out = type(self).from_blocks(result_blocks, [ax.view() for ax in self.axes])

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\internals\blocks.py:607, in Block.astype(self, dtype, errors, squeeze)
    604         raise ValueError("Can not squeeze with more than one column.")
    605     values = values[0, :]  # type: ignore[call-overload]
--> 607 new_values = astype_array_safe(values, dtype, errors=errors)
    609 new_values = maybe_coerce_values(new_values)
    611 refs = None

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\dtypes\astype.py:240, in astype_array_safe(values, dtype, copy, errors)
    237     dtype = dtype.numpy_dtype
    239 try:
--> 240     new_values = astype_array(values, dtype, copy=copy)
    241 except (ValueError, TypeError):
    242     # e.g. _astype_nansafe can fail on object-dtype of strings
    243     #  trying to convert to float
    244     if errors == "ignore":

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\dtypes\astype.py:185, in astype_array(values, dtype, copy)
    182     values = values.astype(dtype, copy=copy)
    184 else:
--> 185     values = _astype_nansafe(values, dtype, copy=copy)
    187 # in pandas we don't store numpy str dtypes, so convert to object
    188 if isinstance(dtype, np.dtype) and issubclass(values.dtype.type, str):

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\dtypes\astype.py:102, in _astype_nansafe(arr, dtype, copy, skipna)
     97     return lib.ensure_string_array(
     98         arr, skipna=skipna, convert_na_value=False
     99     ).reshape(shape)
    101 elif np.issubdtype(arr.dtype, np.floating) and dtype.kind in "iu":
--> 102     return _astype_float_to_int_nansafe(arr, dtype, copy)
    104 elif arr.dtype == object:
    105     # if we have a datetime/timedelta array of objects
    106     # then coerce to datetime64[ns] and use DatetimeArray.astype
    108     if lib.is_np_dtype(dtype, "M"):

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\dtypes\astype.py:146, in _astype_float_to_int_nansafe(values, dtype, copy)
    142 """
    143 astype with a check preventing converting NaN to a meaningless integer value.
    144 """
    145 if not np.isfinite(values).all():
--> 146     raise IntCastingNaNError(
    147         "Cannot convert non-finite values (NA or inf) to integer."
    148         "Replace or remove non-finite values or cast to an integer type"
    149         "that supports these values (e.g. 'Int64')"
    150     )
    151 if dtype.kind == "u":
    152     # GH#45151
    153     if not (values >= 0).all():

IntCastingNaNError: Cannot convert non-finite values (NA or inf) to integer.Replace or remove non-finite values or cast to an integer typethat supports these values (e.g. 'Int64')
KW_PER_HP = 0.7457
cars["hp"] = cars["horsepower"].astype("Int64")
cars["power_kw"] = (cars["horsepower"] * KW_PER_HP).round()
print(cars["hp"].isna().sum(), "cars with no horsepower")
cars[["hp", "power_kw"]].head(3)
6 cars with no horsepower
hp power_kw
0 130 97.0
1 165 123.0
2 150 112.0

Your turn: add the weight in tonnes. Which car is heaviest?

cars["weight_t"] = cars["weight_kg"] / 1000
cars.nlargest(1, "weight_t")[["name", "weight", "weight_t"]]
name weight weight_t
44 pontiac safari (sw) 5140 2.331

Mini-project: did cars get thriftier?#

Fuel use and weight in metric, from 1970 to 1982.

per_year = cars.groupby("year")["l_per_100km"].median()
per_year.iloc[[0, 6, -1]]
year
1970    14.70
1976    11.25
1982     7.40
Name: l_per_100km, dtype: float64
table = cars.pivot_table(index="year", columns="origin",
                         values="l_per_100km", aggfunc="median",
                         observed=True)
table.iloc[[0, -1]]
origin europe japan usa
year
1970 9.4 9.25 15.7
1982 5.9 6.50 8.4
drop = (1 - table.iloc[-1] / table.iloc[0]) * 100
drop.round()
origin
europe    37.0
japan     30.0
usa       46.0
dtype: float64
import matplotlib.pyplot as plt

ax = table.plot(figsize=(8, 4), marker="o")
ax.set_title("Median fuel use by model year and origin")
ax.set_xlabel("Model year")
ax.set_ylabel("Litres per 100 km (lower is better)")
plt.show()
No description has been provided for this image
cars.groupby("origin", observed=True)["weight_kg"].median()
origin
europe    1016.0
japan      977.0
usa       1526.0
Name: weight_kg, dtype: float64
cars["kw_per_t"] = cars["power_kw"] / cars["weight_t"]
cars.groupby("origin", observed=True)["kw_per_t"].median().round(1)
origin
europe    56.7
japan     56.9
usa       56.1
Name: kw_per_t, dtype: float64
import os
os.makedirs("output/pandas", exist_ok=True)
cars.to_csv("output/pandas/l18_cars_metric.csv", index=False)
back = pd.read_csv("output/pandas/l18_cars_metric.csv")
print(back["origin"].dtype)
back = pd.read_csv("output/pandas/l18_cars_metric.csv",
                   dtype={"origin": "category"})
print(back["origin"].dtype)
str
category

Finding: American cars cut fuel use from 15.7 to 8.4 L/100 km. They still used the most fuel, and weighed the most.

Recap#

  • Check dtypes first: text that looks like numbers won't add up
  • pd.to_datetime(format=...) and pd.to_numeric(errors="coerce")
  • Int64 keeps whole numbers with gaps; string for clean text
  • category and downcast shrink memory; avoid blind int8
  • Convert units with named constants; put the unit in the name
  • CSV forgets types: pass dtype= when you read it back

Challenges#

  1. Add a revenue column to the shop data. Which month earned most?
  2. Convert acceleration (seconds to 60 mph) into km/h per second.
  3. Find the 5 products with the most cancelled units.

What's next#

Lesson 19: String Operations and Text Cleaning in Pandas. Splitting, searching and extracting from messy text.

Found this useful?

All lessons, notebooks and datasets here are free. If they helped you, a coffee keeps new lessons coming.