# Pandas Series¶

This tutorial introduces the fundamental building block of pandas, the Series. By the end of this section, you will learn how to create different types of Series, subset them, modify them, and summarize them.

## What is a Series?¶

In the simplest terms, a Series is an ordered collection of values, generally all of the same type. As examples of what you can do with a series, you can have a Series that contains the ages of everyone in your class (a numeric Series), or a Series of all the names of people in your family (a string Series).

This may sound familiar: isn’t that how we described numpy vectors (i.e. one-dimensional numpy arrays)? Yes! In fact, Series are basically one-dimensional numpy arrays with lots of extra features added on top of them. As we’ll see, most everything you could do with a numpy array you can do with a Series; Series can just do more. In particualr, the Series provides the option to use an explicit index (rather than only using the row numbers), as well as some querying and analysis tools which we’ll discuss through this course.

Series are central to pandas because pandas was designed for statistics, and Series are a perfect way to collect lots of different observations of a variable. We’ll see that the apex of pandas functionality which is found in DataFrames is essentially a collection of Series. Understanding Series will enable you to better-understand DataFrames and their value for programming for data science.

## Creating a series¶

There are lots of ways to create Series, but the easiest is to just pass a list or an array to the pd.Series constructor.

To illustrate, let me tell you about a week at the zoo I wish I owned. Here’s what attendance looked like at my zoo last week:

Day of Week

Attendees

Monday

132 people

Tuesday

94 people

Wednesday

112 people

Thursday

84 people

Friday

254 people

Saturday

322 people

Sunday

472 people

Let’s make a Series for this attendance pattern:

[1]:
import pandas as pd  # We have to import pandas to use Series!

attendance = pd.Series([132, 94, 112, 84, 254, 322, 472])
attendance
[1]:
0    132
1     94
2    112
3     84
4    254
5    322
6    472
dtype: int64

## Indices¶

One of the fundamental differences between numpy arrays and Series is that all Series are associated with an index. An index is a set of labels for each observation in a Series. If you don’t specify an index when you create a Series, pandas will create a default index that just labels each row with it’s initial row number, but you can specify an index if you want. When we explored lists and numpy arrays, we also encountered indices which were used to access individual elements. For 2-dimensional arrays or less, these were essentially the row numbers. These implicit indices of the row numbers are also options in Series, but there’s the option to explicitly label your indices in ways that can make your data easier to analyze.

In the case below, for example, we know that these entries are associated with different days of the week, so let’s specify an index for our attendance Series:

[2]:
attendance = pd.Series(
[132, 94, 112, 84, 254, 322, 472],
index=[
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
],
)
attendance
[2]:
Monday       132
Tuesday       94
Wednesday    112
Thursday      84
Friday       254
Saturday     322
Sunday       472
dtype: int64

Now as we see the rows are labeled with days of the week on the left side, rather than with initial row numbers.

Note that you can always access a Series’ index with the .index property:

[3]:
attendance.index

[3]:
Index(['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday',
'Sunday'],
dtype='object')

An important property of index labels is that they stay with each row, even if you sort your data. So if I sort my Series by attendance, not only will rows re-order, but so will the index labels:

[4]:
attendance = attendance.sort_values()
attendance

[4]:
Thursday      84
Tuesday       94
Wednesday    112
Monday       132
Friday       254
Saturday     322
Sunday       472
dtype: int64

Note: This seems intuitive with days-of-the-week as our index labels, but it can be confusing when your index starts out as row numbers. For example, if you had not changed our index to be days of the week, then the default index would look like the index labels were just row numbers. But if we then sort the Series, the numbers will shuffle, and they will no longer correspond to row numbers:

[5]:
attendance = pd.Series([132, 94, 112, 84, 254, 322, 472])
attendance

[5]:
0    132
1     94
2    112
3     84
4    254
5    322
6    472
dtype: int64
[6]:
attendance = attendance.sort_values()
attendance

[6]:
3     84
1     94
2    112
0    132
4    254
5    322
6    472
dtype: int64

# Relationship to numpy arrays¶

As you’ve probably noted, there are some similarities in the construction of pandas Series with how we constructed numpy arrays. The biggest difference is the index. In the figure below we can see the code to construct both a numpy array and a pandas Series containing the same entries, however, we can also add in explicit index labels that may be easier for us to read or to use to access the row contents. For example, if we were representing financial data for three days of a week: ‘mon’, tue’, and ‘wed’, we may be able do do this as shown in the figure below. Once we’ve created our pandas Series, we can return a numpy array. if b contains a pandas Series, then we can return a numpy array with b.values.

While we will discuss how to access the data in these data structures in more detail in the next lesson, we can rather easily access an entry using the index or its assigned label. There is some nuance in using row numbers since as we saw above with the sort_values() example, the default ordering of row number based indices can change, so the iloc method will allow us to always return the $$i^{th}$$ entry regardless of how the Series has been sorted. More on that later, though…

Going forward, we want to be crystal clear when we’re discussing pandas Series or DataFrames instead of when we are talking about numpy arrays. Pictorally, we’ll represent those as little tables as shown below with its key characteristics labeled:

## Types of Series¶

Before we dive too far into Series manipulations, it’s important to talk about datatypes. Every Series, as we will see, has a “dtype” (short for datatype). The dtype of a Series is important to understand because a Series’ dtype determines what manipulations you can apply to that series.

There are, broadly, two types of Series:

• Numeric: these hold numbers that pandas understands are numbers. Specific numeric datatypes include things like int64, and int32 (integers), or float64 and float32 (floating point numbers).

• Object: these are Series that can hold any Python object, like strings, numbers, Sets, you name it. They have dtype O for “objects”. They are flexible, but also very slow and actually harder to work with.

Numeric Series are by far the easiest to work with, and are generally either integers (int64, int32, etc.) or floating point numbers (float64, float32). Integer Series (datatypes that start with int) can only hold… well, integers (whole numbers), while floating point numbers Series (datatypes that start with float) can hold integers, numbers with decimal points, and even missing values.

The numbers at the end of these types (64, 32, etc.) have to do with how many actual bits of data are allocated to each number. For the moment, the differences between them don’t matter, and in general you’ll likely always see (and should use) the 64 suffix.

You can check the dtype of a Series by typing .dtype. For example, here are some different kinds of Series:

[7]:
s = pd.Series([1, 2, 3])
s.dtype

[7]:
dtype('int64')
[8]:
s = pd.Series([1, 2, 3.14])
s.dtype

[8]:
dtype('float64')
[9]:
s = pd.Series([1, 2, "a string"])
s.dtype

[9]:
dtype('O')

As you can see, integer (int64) Series can only hold integers. If we add one number with a decimal component, the whole thing becomes a float64. Similarly, floating point Series can only hold numbers. If we add a single String, the whole thing becomes an Object (O) type.

### Converting datatypes¶

If you want to change the datatype of a Series, you can do so with the .asdtype() method… provided a conversion is possible! For example, you can always convert integer arrays to floating point Series because a whole number can be represented as a floating point number (just trust me on this for now… we’ll discuss why later!).

[10]:
s = pd.Series([1, 2, 3])
s = s.astype("float64")
s
[10]:
0    1.0
1    2.0
2    3.0
dtype: float64

But be careful: since integers can’t ever hold decimals, if you try and convert a floating point Series to an integer Series, it will just drop the decimal part of numbers with decimals!

[11]:
s = pd.Series([1, 2, 3.14])
s = s.astype("int64")
s
[11]:
0    1
1    2
2    3
dtype: int64

Note Pandas is just doing the same thing regular python would do:

[12]:
int(3.14)

[12]:
3

But if you try and convert an “object” Series to numeric and there are numbers that can’t be converted, pandas will throw an error:

s = pd.Series([1, 2, "a string"])
s.astype('float64')

---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
/Users/nce8/github/practicaldatascience/source/10_pandas_series.ipynb Cell 32 in <cell line: 2>()
1 s = pd.Series([1, 2, "a string"])
----> 2 s.astype('float64')

...

File ~/opt/miniconda3/lib/python3.10/site-packages/pandas/core/dtypes/cast.py:1181, in astype_nansafe(arr, dtype, copy, skipna)
1177     raise ValueError(msg)
1179 if copy or is_object_dtype(arr.dtype) or is_object_dtype(dtype):
1180     # Explicit copy, or required since NumPy can't view from / to object.
-> 1181     return arr.astype(dtype, copy=True)
1183 return arr.astype(dtype, copy=copy)

ValueError: could not convert string to float: 'a string'

## Computation with Series¶

One of the nice things about Series is that, like numpy arrays, we can easily do things like multiply all the values by another number easily. For example, suppose tickets to my zoo cost \$15 per person. What is the total money generated by ticket sales each day? Let’s find out!

[ ]:
attendance = pd.Series(
[132, 94, 112, 84, 254, 322, 472],
index=[
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
],
)
attendance
Monday       132
Tuesday       94
Wednesday    112
Thursday      84
Friday       254
Saturday     322
Sunday       472
dtype: int64
[ ]:
revenue = attendance * 15
revenue

Monday       1980
Tuesday      1410
Wednesday    1680
Thursday     1260
Friday       3810
Saturday     4830
Sunday       7080
dtype: int64

Now what if we want to know to the total amount raised in a week, instead of just the amount on each day? We can use one of pandas’s many helper functions – in this case sum – which adds up all the values of a Series

[ ]:
revenue.sum()

22050

Cool!

This is an example of one of the three forms of Series arithmetic:

1. Operations involving a Series with more than one element a single element (as shown above).

1. A Series modified by a function.

[ ]:
revenue**2

Monday        3920400
Tuesday       1988100
Wednesday     2822400
Thursday      1587600
Friday       14516100
Saturday     23328900
Sunday       50126400
dtype: int64
1. Two Series with the same number of elements. When working with two Series, elements are matched based on index values, not row numbers.

This third one is a bit trickier, so let’s create a set of Series to demonstrate:

[ ]:
a = pd.Series(data=[1, 2, 3.2], index=["mon", "tue", "wed"])
a
mon    1.0
tue    2.0
wed    3.2
dtype: float64
[ ]:
b = pd.Series(data=[4.0, 5, 6], index=["tue", "wed", "fri"])
b
tue    4.0
wed    5.0
fri    6.0
dtype: float64

Note that the indices are not the same , although ‘tue’ and ‘wed’ are in both Series. Let’s multiply them together:

[ ]:
c = a * b
c

fri     NaN
mon     NaN
tue     8.0
wed    16.0
dtype: float64

Two Series multipled together that have explicit index labels perform the operation by matching the indices. If the indices do not match, it returns a NaN value in its place. Of course, with the default row number based indices, if the data have not been sorted, they multiple element-wise as you would expect:

[ ]:
a = pd.Series(data=[1, 2, 3.2])
a
0    1.0
1    2.0
2    3.2
dtype: float64
[ ]:
b = pd.Series(data=[4.0, 5, 6])
b
0    4.0
1    5.0
2    6.0
dtype: float64
[ ]:
c = a * b
c

0     4.0
1    10.0
2    19.2
dtype: float64

But note that the types of things you can do with a Series depends on the Series dtype. Math functions, for example, can only be applied to numeric datatypes!

### Summarizing with Functions¶

We often want to get summary statistics from a Series—that is, learn something general about it by looking beyond its constituent elements. If we have a Series in which each element represents a person’s height, we may want to know who the shortest or tallest person is, what the median or mean height is, what the standard deviation is, etc. Here are common summary facts for numeric Series (some also work for object types):

my_numbers = pd.Series([1, 2, 3, 4])

my_numbers.dtype #check the dtype
len(my_numbers) #number of elements
my_numbers.max() #maximum value
my_numbers.min() #minimum value
my_numbers.sum() #sum of all values in the Series
my_numbers.mean() #mean
my_numbers.median() #median
my_numbers.var() #variance
my_numbers.std() #standard deviation
my_numbers.quantile() #return specified quantile, 0.5 if none specified
my_numbers.describe() #function that contains many summary stats from above
my_numbers.value_counts() # Tabulate out all the values. Add the argument normalize=True to get shares in each big.

Of those, two of the most powerful are .describe() (for numeric Series that take on lots of values):

[ ]:
my_numbers = pd.Series(range(100))
my_numbers.describe()

count    100.000000
mean      49.500000
std       29.011492
min        0.000000
25%       24.750000
50%       49.500000
75%       74.250000
max       99.000000
dtype: float64

and .value_counts() for numeric series with only a few unique values:

[ ]:
my_numbers = pd.Series([1, 2, 2, 2, 2, 1, 1, -1, -1])
my_numbers.value_counts()

2    4
1    3
-1    2
dtype: int64

Note that .value_counts() can be combined with the normalize=True argument to get the share (i.e. proportion) of observations that have each unique value, rather than the count:

[ ]:
my_numbers.value_counts(normalize=True)

2    0.444444
1    0.333333
-1    0.222222
dtype: float64

## Summary¶

pandas Series are dynamic tools for representing a wide array of data types than (in general) numpy arrays. While numpy is the preferred tool for highly specialized numerical processing, pandas Series provide an important step towards understanding the representational and querying power of pandas for analyzing tabular data, which is an extremely common and important type of data for data scientists to work with. The Series introduces the idea of labeled and sortable indices which will enhance our ability to query our data. In the next lesson, we will explore how to work with the contents of a pandas Series.