Mathew K Analytics

Lesson 3 · Pandas Full Course

Pandas Series vs DataFrame Explained: The 2 Objects Behind Every Table | Pandas Course #3

In this lesson you'll be able to: Build a Series and read its values, index and type Pull Series out of a real DataFrame and summarise them Explain how a…

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 03: Understanding Series and DataFrames#

In this lesson you'll be able to:

  • Build a Series and read its values, index and type
  • Pull Series out of a real DataFrame and summarise them
  • Explain how a DataFrame is a set of Series sharing one index
  • Use index alignment and column maths to create new features Dataset: the Titanic passenger list, from start to finish

Warm-up: load it the lesson 02 way#

A pathlib path, checked before loading.

from pathlib import Path
import pandas as pd

path = Path("data") / "pandas" / "titanic.csv"
print(path.exists())
df = pd.read_csv(path)
print(df.shape)
True
(891, 12)

A Series: one column of values with labels#

Values, plus an index that names each one.

four = df.head(4)
ages = pd.Series(four["Age"].to_list())
ages
0    22.0
1    38.0
2    26.0
3    35.0
dtype: float64
print(ages.values)
print(ages.index)
print(ages.dtype, len(ages))
[22. 38. 26. 35.]
RangeIndex(start=0, stop=4, step=1)
float64 4
names = four["Name"].str.split(",").str[0].to_list()
ages = pd.Series(four["Age"].to_list(), index=names, name="Age")
ages
Braund       22.0
Cumings      38.0
Heikkinen    26.0
Futrelle     35.0
Name: Age, dtype: float64
print(ages["Cumings"])
print(ages.mean())
print(ages > 30)
38.0
30.25
Braund       False
Cumings       True
Heikkinen    False
Futrelle      True
Name: Age, dtype: bool
print(ages.idxmax(), ages.max())
ages.sort_values(ascending=False)
Cumings 38.0
Cumings      38.0
Futrelle     35.0
Heikkinen    26.0
Braund       22.0
Name: Age, dtype: float64
ages.describe()
count     4.00
mean     30.25
std       7.50
min      22.00
25%      25.00
50%      30.50
75%      35.75
max      38.00
Name: Age, dtype: float64
counts = df["Embarked"].value_counts().to_dict()
print(counts)
ports = pd.Series(counts)
print(ports.index.tolist())
print(ports["C"] + ports["Q"])
{'S': 644, 'C': 168, 'Q': 77}
['S', 'C', 'Q']
245

Common mistake: asking for position 0 with square brackets

ages[0]
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\indexes\base.py:3641, in Index.get_loc(self, key)
   3640 try:
-> 3641     return self._engine.get_loc(casted_key)
   3642 except KeyError as err:

File pandas/_libs/index.pyx:168, in pandas._libs.index.IndexEngine.get_loc()

File pandas/_libs/index.pyx:176, in pandas._libs.index.IndexEngine.get_loc()

File pandas/_libs/index.pyx:583, in pandas._libs.index.StringObjectEngine._check_type()

KeyError: 0

The above exception was the direct cause of the following exception:

KeyError                                  Traceback (most recent call last)
Cell In[10], line 1
----> 1 ages[0]

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\series.py:959, in Series.__getitem__(self, key)
    954     key = unpack_1tuple(key)
    956 elif key_is_scalar:
    957     # Note: GH#50617 in 3.0 we changed int key to always be treated as
    958     #  a label, matching DataFrame behavior.
--> 959     return self._get_value(key)
    961 # Convert generator to list before going through hashable part
    962 # (We will iterate through the generator there to check for slices)
    963 if is_iterator(key):

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\series.py:1046, in Series._get_value(self, label, takeable)
   1043     return self._values[label]
   1045 # Similar to Index.get_value, but we do not fall back to positional
-> 1046 loc = self.index.get_loc(label)
   1048 if is_integer(loc):
   1049     return self._values[loc]

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\indexes\base.py:3648, in Index.get_loc(self, key)
   3643     if isinstance(casted_key, slice) or (
   3644         isinstance(casted_key, abc.Iterable)
   3645         and any(isinstance(x, slice) for x in casted_key)
   3646     ):
   3647         raise InvalidIndexError(key) from err
-> 3648     raise KeyError(key) from err
   3649 except TypeError:
   3650     # If we have a listlike key, _check_indexing_error will raise
   3651     #  InvalidIndexError. Otherwise we fall through and re-raise
   3652     #  the TypeError.
   3653     self._check_indexing_error(key)

KeyError: 0
print(ages.iloc[0])
print(ages.loc["Braund"])
22.0
22.0

Pull a Series out of a real table#

Every column of a DataFrame is a Series with the same index.

age = df["Age"]
print(type(age))
print(age.name, len(age), age.count())
<class 'pandas.Series'>
Age 891 714
print(age.mean().round(1), age.median(), age.max())
print((age > 60).sum(), "passengers over 60")
29.7 28.0 80.0
22 passengers over 60
filled = age.fillna(age.median())
print(filled.count(), age.count())
print(filled.mean().round(2))
891 714
29.36
youngest = age.nsmallest(3)
df.loc[youngest.index, ["Name", "Age"]]
Name Age
803 Thomas, Master. Assad Alexander 0.42
755 Hamalainen, Master. Viljo 0.67
469 Baclini, Miss. Helene Barbara 0.75
df["Sex"].value_counts()
Sex
male      577
female    314
Name: count, dtype: int64
full = {"S": "Southampton", "C": "Cherbourg", "Q": "Queenstown"}
port = df["Embarked"].map(full)
port.value_counts(dropna=False)
Embarked
Southampton    644
Cherbourg      168
Queenstown      77
NaN              2
Name: count, dtype: int64

Quick check: what's the difference between df["Age"] and df[["Age"]]?

print(type(df["Age"]), df["Age"].shape)
print(type(df[["Age"]]), df[["Age"]].shape)
<class 'pandas.Series'> (891,)
<class 'pandas.DataFrame'> (891, 1)
first = df.loc[0]
print(type(first))
first[["Name", "Age", "Fare"]]
<class 'pandas.Series'>
Name    Braund, Mr. Owen Harris
Age                        22.0
Fare                       7.25
Name: 0, dtype: object

Your turn: how many passengers paid a fare below 10?

fare = df["Fare"]
print((fare < 10).sum(), "fares below 10")
print((fare < 10).mean().round(2), "as a share")
336 fares below 10
0.38 as a share

A DataFrame: Series that share one index#

Build one from a dictionary: keys become columns.

people = pd.DataFrame({
    "Age": four["Age"].to_list(),
    "Class": four["Pclass"].to_list(),
    "Survived": four["Survived"].to_list(),
}, index=names)
people
Age Class Survived
Braund 22.0 3 0
Cumings 38.0 1 1
Heikkinen 26.0 3 1
Futrelle 35.0 1 1
print(people.columns.tolist())
print(people.index.tolist())
print(people.shape)
['Age', 'Class', 'Survived']
['Braund', 'Cumings', 'Heikkinen', 'Futrelle']
(4, 3)
print(people.dtypes)
Age         float64
Class         int64
Survived      int64
dtype: object
col = people["Class"]
print(type(col))
print(col.index.equals(people.index))
<class 'pandas.Series'>
True
people["Fare"] = four["Fare"].to_list()
people.loc["Heikkinen"]
Age         26.000
Class        3.000
Survived     1.000
Fare         7.925
Name: Heikkinen, dtype: float64
people.mean().round(2)
Age         30.25
Class        2.00
Survived     0.75
Fare        34.89
dtype: float64
people.T
Braund Cumings Heikkinen Futrelle
Age 22.00 38.0000 26.000 35.0
Class 3.00 1.0000 3.000 1.0
Survived 0.00 1.0000 1.000 1.0
Fare 7.25 71.2833 7.925 53.1
records = df[["Sex", "Age"]].head(2).to_dict("records")
print(records)
pd.DataFrame(records)
[{'Sex': 'male', 'Age': 22.0}, {'Sex': 'female', 'Age': 38.0}]
Sex Age
0 male 22.0
1 female 38.0
df.dtypes.value_counts()
int64      5
str        5
float64    2
Name: count, dtype: int64

Common mistake: columns of different lengths

ages3 = df["Age"].head(3).to_list()
classes2 = df["Pclass"].head(2).to_list()
pd.DataFrame({"Age": ages3, "Class": classes2})
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[30], line 3
      1 ages3 = df["Age"].head(3).to_list()
      2 classes2 = df["Pclass"].head(2).to_list()
----> 3 pd.DataFrame({"Age": ages3, "Class": classes2})

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\frame.py:769, in DataFrame.__init__(self, data, index, columns, dtype, copy)
    763     mgr = self._init_mgr(
    764         data, axes={"index": index, "columns": columns}, dtype=dtype, copy=copy
    765     )
    767 elif isinstance(data, dict):
    768     # GH#38939 de facto copy defaults to False only in non-dict cases
--> 769     mgr = dict_to_mgr(data, index, columns, dtype=dtype, copy=copy)
    770 elif isinstance(data, ma.MaskedArray):
    771     from numpy.ma import mrecords

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\internals\construction.py:460, in dict_to_mgr(data, index, columns, dtype, copy)
    441 if copy:
    442     # We only need to copy arrays that will not get consolidated, i.e.
    443     #  only EA arrays
    444     arrays = [
    445         (
    446             x.copy()
   (...)
    457         for x in arrays
    458     ]
--> 460 return arrays_to_mgr(arrays, columns, index, dtype=dtype, consolidate=copy)

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\internals\construction.py:113, in arrays_to_mgr(arrays, columns, index, dtype, verify_integrity, consolidate)
    110 if verify_integrity:
    111     # figure out the index, if necessary
    112     if index is None:
--> 113         index = _extract_index(arrays)
    114     else:
    115         index = ensure_index(index)

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\internals\construction.py:643, in _extract_index(data)
    641 if have_raw_arrays:
    642     if len(raw_lengths) > 1:
--> 643         raise ValueError("All arrays must be of the same length")
    645     if have_dicts:
    646         raise ValueError(
    647             "Mixing dicts with non-Series may lead to ambiguous ordering."
    648         )

ValueError: All arrays must be of the same length
classes3 = df["Pclass"].head(3).to_list()
pd.DataFrame({"Age": ages3, "Class": classes3})
Age Class
0 22.0 3
1 38.0 1
2 26.0 3

Your turn: build a DataFrame from two Series.

two = pd.DataFrame({"Name": df["Name"], "Fare": df["Fare"]})
print(two.shape)
two.head(3)
(891, 2)
Name Fare
0 Braund, Mr. Owen Harris 7.2500
1 Cumings, Mrs. John Bradley (Florence Briggs Th... 71.2833
2 Heikkinen, Miss. Laina 7.9250

Labels line up: index alignment#

Maths between Series matches labels, not positions.

a = df.loc[df["Survived"] == 1, "Embarked"].value_counts()
b = df.loc[df["Survived"] == 0, "Embarked"].value_counts().sort_index()
print(a.index.tolist(), b.index.tolist())
a + b
['S', 'C', 'Q'] ['C', 'Q', 'S']
Embarked
C    168
Q     77
S    644
Name: count, dtype: int64
c = b.head(2)
print(a + c)
print(a.add(c, fill_value=0))
Embarked
C    168.0
Q     77.0
S      NaN
Name: count, dtype: float64
Embarked
C    168.0
Q     77.0
S    217.0
Name: count, dtype: float64
alive = df.loc[df["Survived"] == 1, "Pclass"].value_counts()
total = df["Pclass"].value_counts().sort_index()
print(alive.index.tolist(), total.index.tolist())
[1, 3, 2] [1, 2, 3]
total.reindex([1, 2, 3, 4])
Pclass
1    216.0
2    184.0
3    491.0
4      NaN
Name: count, dtype: float64

Quick check: the orders differ. Is alive / total still right?

(alive / total).round(2)
Pclass
1    0.63
2    0.47
3    0.24
Name: count, dtype: float64

Quick check: what if we divide the raw .values instead?

raw = alive.values / total.values
print(raw.round(2))
[0.63 0.65 0.18]

Column maths: build new columns from old ones#

Maths on a whole column at once, no loops.

df["FamilySize"] = df["SibSp"] + df["Parch"] + 1
df[["Name", "SibSp", "Parch", "FamilySize"]].head(3)
Name SibSp Parch FamilySize
0 Braund, Mr. Owen Harris 1 0 2
1 Cumings, Mrs. John Bradley (Florence Briggs Th... 1 0 2
2 Heikkinen, Miss. Laina 0 0 1
df["FamilySize"].value_counts().sort_index()
FamilySize
1     537
2     161
3     102
4      29
5      15
6      22
7      12
8       6
11      7
Name: count, dtype: int64
df["IsAlone"] = (df["FamilySize"] == 1).astype(int)
print(df["IsAlone"].sum(), "travelled alone")
537 travelled alone

Your turn: add a 0/1 column IsChild for ages under 16.

df["IsChild"] = (df["Age"] < 16).astype(int)
print(df["IsChild"].sum(), "children")
83 children
young_adult = df["Age"].between(18, 30)
print(young_adult.sum(), "passengers aged 18 to 30")
296 passengers aged 18 to 30
fare_each = df["Fare"] / df["FamilySize"]
print(type(fare_each))
print(fare_each.round(2).head(3).tolist())
<class 'pandas.Series'>
[3.62, 35.64, 7.92]

Common mistake: turning a column with gaps into whole numbers

df["Age"].astype(int)
---------------------------------------------------------------------------
IntCastingNaNError                        Traceback (most recent call last)
Cell In[45], line 1
----> 1 df["Age"].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')
whole = df["Age"].round().astype("Int64")
print(whole.head(6).tolist())
print(whole.dtype, whole.isna().sum())
[22, 38, 26, 35, 35, <NA>]
Int64 177
summary = df["Fare"].describe()
summary.to_frame().T.round(2)
count mean std min 25% 50% 75% max
Fare 891.0 32.2 49.69 0.0 7.91 14.45 31.0 512.33

Mini-project: did travelling alone help?#

Use the IsAlone column we built, and compare fairly.

rates = df.groupby("IsAlone")["Survived"].agg(["count", "mean"])
rates.round(2)
count mean
IsAlone
0 354 0.51
1 537 0.30
by_size = df.groupby("FamilySize")["Survived"].mean()
by_size.round(2)
FamilySize
1     0.30
2     0.55
3     0.58
4     0.72
5     0.20
6     0.14
7     0.33
8     0.00
11    0.00
Name: Survived, dtype: float64
pd.crosstab(df["Sex"], df["IsAlone"], normalize="columns").round(2)
IsAlone 0 1
Sex
female 0.53 0.23
male 0.47 0.77
fair = df.pivot_table(values="Survived", index="Sex",
                      columns="IsAlone", aggfunc="mean")
fair.round(2)
IsAlone 0 1
Sex
female 0.71 0.79
male 0.27 0.16
by_class = df.pivot_table(values="Survived", index="Pclass",
                          columns="IsAlone", aggfunc="mean")
by_class.round(2)
IsAlone 0 1
Pclass
1 0.73 0.53
2 0.64 0.35
3 0.30 0.21
import matplotlib.pyplot as plt

ax = fair.plot(kind="bar", rot=0)
ax.set_title("Survival by sex: with family vs alone")
ax.set_xlabel("Sex")
ax.set_ylabel("Share who survived")
ax.legend(["With family", "Alone"])
plt.show()
No description has been provided for this image

Finding: alone looks deadly overall (0.30 vs 0.51), but most loners were men. Within each sex the gap shrinks; alone women even did better. Within each class, though, loners still fared worse.

Recap#

  • A Series is values plus an index; .values, .index, .dtype
  • loc means label, iloc means position
  • A DataFrame is Series sharing one index; df["col"] is a Series
  • Maths between Series aligns on labels; missing labels give NaN
  • Build columns with whole-column maths, like SibSp + Parch + 1

Challenges#

  1. Make a Series of fares indexed by Name. Who paid the most?
  2. Compare survival for family sizes 1, 2-4 and 5+ with pd.cut.
  3. Divide two value_counts Series to get survival by port.

What's next#

Lesson 04: Creating and Inspecting DataFrames. Build tables from lists, dicts and files, then inspect them fast.

Found this useful?

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