<jupyter_start><jupyter_text>Indexing time series data in pandasQuite often the data that we want to analyze has a time based component. Think about data like daily temperatures or rainfall, stock prices, sales data, student attendance, or events like clicks or views of a web application. There is no shortage of sources of data, and new sources are being added all the time. As a result, most pandas users will need to be familiar with time series data at some point. A time series is just a pandas ```DataFrame``` or ```Series``` that has a time based index. The values in the time series can be anything else that can be contained in the containers, they are just accessed using date or time values. A time series container can be manipulated in many ways in pandas, but for this article I will focus just on the basics of indexing. Knowing how indexing works first is important for data exploration and use of more advanced features.  DatetimeIndexIn pandas, a ```DatetimeIndex``` is used to provide indexing for pandas ```Series``` and ```DataFrame```s and works just like other ```Index``` types, but provides special functionality for time series operations. We'll cover the common functionality with other ```Index``` types first, then talk about the basics of partial string indexing.One word of warning before we get started. It's important for your index to be sorted, or you may get some strange results. ExamplesTo show how this functionality works, let's create some sample time series data with different time resolutions.<jupyter_code>import pandas as pd
import numpy as np

import datetime

# this is an easy way to create a DatetimeIndex
# both dates are inclusive
d_range = pd.date_range("2021-01-01", "2021-01-20")

# this creates another DatetimeIndex, 10000 minutes long
m_range = pd.date_range("2021-01-01", periods=10000, freq="T")

# daily data in a Series
daily = pd.Series(np.random.rand(len(d_range)), index=d_range)
# minute data in a DataFrame
minute = pd.DataFrame(np.random.rand(len(m_range), 1),
                      columns=["value"],
                      index=m_range)

# time boundaries not on the minute boundary, add some random jitter
mr_range = m_range + pd.Series([pd.Timedelta(microseconds=1_000_000.0 * s)
                                for s in np.random.rand(len(m_range))]) 
# minute data in a DataFrame, but at a higher resolution
minute2 = pd.DataFrame(np.random.rand(len(mr_range), 1),
                       columns=["value"],
                       index=mr_range)

daily.head()
minute.head()
minute2.head()<jupyter_output><empty_output><jupyter_text>ResolutionA ```DatetimeIndex``` has a resolution that indicates to what level the ```Index``` is indexing the data. The three indices created above have distinct resolutions. This will have ramifications in how we index later on.<jupyter_code>print("daily:", daily.index.resolution)
print("minute:", minute.index.resolution)
print("randomized minute:", minute2.index.resolution)<jupyter_output><empty_output><jupyter_text>Typical indexingBefore we get into some of the "special" ways to index a pandas ```Series``` or ```DataFrame``` with a ```DatetimeIndex```, let's just look at some of the typical indexing functionality. BasicsI've covered the basics of indexing before, so I won't cover too many details here. However it's important to realize that a ```DatetimeIndex``` works just like other indices in pandas, but has extra functionality. (The extra functionality can be more useful and convenient, but just hold tight, those details are next). If you already understand basic indexing, you may want to skim until you get to partial string indexing. If you haven't read my articles on indexing, you should start with the [basics](https://www.wrighters.io/indexing-and-selecting-in-pandas-part-1/) and go from there.Indexing a ```DatetimeIndex``` using a ```datetime```-like object will use [exact indexing](https://pandas.pydata.org/docs/user_guide/timeseries.htmlexact-indexing). ```getitem``` a.k.a the array indexing operator (```[]```)When using ```datetime```-like objects for indexing, we need to match the resolution of the index.This ends up looking fairly obvious for our daily time series.<jupyter_code>daily[pd.Timestamp("2021-01-01")]
try:
    minute[pd.Timestamp("2021-01-01 00:00:00")]
except KeyError as ke:
    print(ke)<jupyter_output><empty_output><jupyter_text>This ```KeyError``` is raised because in a ```DataFrame```, using a single argument to the ```[]``` operator will look for a *column*, not a row. We have a single column called ```value``` in our ```DataFrame```, so the code above is looking for a column. Since there isn't a column by that name, there is a ```KeyError```. We will use other methods for indexing rows in a ```DataFrame```. ```.iloc``` indexingSince the ```iloc``` indexer is integer offset based, it's pretty clear how it works, not much else to say here. It works the same for all resolutions.<jupyter_code>daily.iloc[0]
minute.iloc[-1]
minute2.iloc[4]<jupyter_output><empty_output><jupyter_text>```.loc``` indexingWhen using ```datetime```-like objects, you need to have exact matches for single indexing. It's important to realize that when you make ```datetime``` or ```pd.Timestamp``` objects, all the fields you don't specify explicitly will default to 0.<jupyter_code>jan1 = datetime.datetime(2021, 1, 1)
daily.loc[jan1]
minute.loc[jan1]  # the defaults for hour, minute, second make this work
try:
    # we don't have that exact time, due to the jitter
    minute2.loc[jan1] 
except KeyError as ke:
    print("Missing in index: ", ke)
# but we do have a value on that day
# we could construct it manually to the microsecond if needed
jan1_ms = datetime.datetime(2021, 1, 1, 0, 0, 0, microsecond=minute2.index[0].microsecond)
minute2.loc[jan1_ms]<jupyter_output><empty_output><jupyter_text>SlicingSlicing with integers works as expected, you can read more about regular slicing [here](https://www.wrighters.io/indexing-and-selecting-in-pandas-slicing/). But here's a few examples of "regular" slicing, which works with the array indexing operator (```[]```) or the ```.iloc``` indexer.<jupyter_code>daily[0:2] # first two, end is not inclusive
minute[0:2] # same
minute2[1:5:2]  # every other
minute2.iloc[1:5:2] # works with the iloc indexer as well<jupyter_output><empty_output><jupyter_text>Slicing with ```datetime```-like objects also works. Note that the end item is inclusive, and the defaults for hours, minutes, seconds, and microseconds will set the cutoff for the randomized data on minute boundaries (in our case).<jupyter_code>daily[datetime.date(2021,1,1):datetime.date(2021, 1,3)] # end is inclusive
minute[datetime.datetime(2021, 1, 1): datetime.datetime(2021, 1, 1, 0, 2, 0)]
minute2[datetime.datetime(2021, 1, 1): datetime.datetime(2021, 1, 1, 0, 2, 0)]<jupyter_output><empty_output><jupyter_text>This sort of slicing work with ```[]``` and ```.loc```, but not ```.iloc```, as expected. Remember, ```.iloc``` is for integer offset indexing.<jupyter_code>minute2.loc[datetime.datetime(2021, 1, 1): datetime.datetime(2021, 1, 1, 0, 2, 0)]
try:
    # no! use integers with iloc
    minute2.iloc[datetime.datetime(2021, 1, 1): datetime.datetime(2021, 1, 1, 0, 2, 0)]
except TypeError as te:
    print(te)<jupyter_output><empty_output><jupyter_text>Special indexing with stringsNow things get really interesting and helpful. When working with time series data, partial string indexing can be very helpful and way less cumbersome than working with ```datetime``` objects. I know we started with objects, but now you see that for interactive use and exploration, strings are very helpful. You can pass in a string that can be parsed as a full date, and it will work for indexing.<jupyter_code>daily["2021-01-04"]
minute.loc["2021-01-01 00:03:00"]<jupyter_output><empty_output><jupyter_text>Strings also work for slicing.<jupyter_code>minute.loc["2021-01-01 00:03:00":"2021-01-01 00:05:00"] # end is inclusive<jupyter_output><empty_output><jupyter_text>Partial String IndexingPartial strings can also be used, so you only need to specify part of the data. This can be useful for pulling out a single year, month, or day from a longer dataset.<jupyter_code>daily["2021"]    # all items match (since they were all in 2021)
daily["2021-01"] # this one as well (and only in January for our data)<jupyter_output><empty_output><jupyter_text>You can do this on a ```DataFrame``` as well.<jupyter_code>minute["2021-01-01"]<jupyter_output><empty_output><jupyter_text>See that deprecation warning? You should no longer use ```[]``` for ```DataFrame``` string indexing (as we saw above, ```[]``` should be used for column access, not rows). Depending on whether the value is found in the index or not, you may get an error or a warning. Use ```.loc``` instead so you can avoid the confusion.<jupyter_code>minute2.loc["2021-01-01"]<jupyter_output><empty_output><jupyter_text>If using string slicing, the end point includes *all times in the day*.<jupyter_code>minute2.loc["2021-01-01":"2021-01-02"]<jupyter_output><empty_output><jupyter_text>But if we include times, it will include partial periods, cutting off the end right up to the microsecond if it is specified.<jupyter_code>minute2.loc["2021-01-01":"2021-01-02 13:32:01"]<jupyter_output><empty_output><jupyter_text>Slicing vs. exact matchingOur three datasets have different resolutions in their index: day, minute, and microsecond respectively. If we pass in a string indexing parameter and the resolution of the string is *less* accurate than the index, it will be treated as a slice. If it's the same or more accurate, it's treated as an exact match. Let's use our microsecond (```minute2```) and minute (```minute```) resolution data examples. Note that every time you get a slice of the ```DataFrame```, the value returned is a ```DataFrame```. When it's an exact match, it's a ```Series```.<jupyter_code>minute2.loc["2021-01-01"]          # slice - the entire day
minute2.loc["2021-01-01 00"]       # slice - the first hour of the day
minute2.loc["2021-01-01 00:00"]    # slice - the first minute of the day
minute2.loc["2021-01-01 00:00:00"] # slice - the first minute and second of the day
print(str(minute2.index[0]))       # note the string representation include the full microseconds
minute2.loc[str(minute2.index[0])] # slice - this seems incorrect to me, should return Series not DataFrame
minute2.loc[minute2.index[0]]      # exact match
minute.loc["2021-01-01"]       # slice - the entire day
minute.loc["2021-01-01 00"]    # slice - the first hour of the day
minute.loc["2021-01-01 00:00"] # exact match<jupyter_output><empty_output><jupyter_text>Note that for a microsecond resolution string match, I don't see an exact match (where the return would be a ```Series```), but instead a slice match (because the return value is a ```DataFrame```). On the minute resolution ```DataFrame``` it worked as I expected. asofOne way to deal with this sort of issue is to use ```asof```. Often, when you have data that is either randomized in time or may have missing values, getting the most recent value as of a certain time is preffered. You could do this yourself, but it looks little cleaner to use ```asof```.<jupyter_code>minute2.loc[:"2021-01-01 00:00:03"].iloc[-1]
# vs
minute2.asof("2021-01-01 00:00:03")<jupyter_output><empty_output><jupyter_text>truncateYou can also use ```truncate``` which is sort of like slicing. You specify a value of ```before``` or  ```after``` (or both) to indicate cutoffs for data. Unlike slicing which includes all values that partially match the date, ```truncate``` assumes 0 for any unspecified values of the date.<jupyter_code>minute2.truncate(after="2021-01-01 00:00:03")<jupyter_output><empty_output>
{'fraction_non_alphanumeric': 0.109375, 'fraction_numerical': 0.037533967391304345, 'mean_word_length': 5.061245496654657, 'pattern_counts': {'":': 3, '<': 73, '<?xml version=': 0, '>': 73, 'https://': 3, 'lorem ipsum': 0, 'www.': 2, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 2, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '18', 'dir': 'jupyter-structured-clean-dedup', 'id': '507251', 'license': 'CC0-1.0', 'n_tokens_mistral': 3754, 'n_tokens_neox': 3413, 'n_words': 1437, 'path': 'pandas/pandas_indexing_7.ipynb', 'repo_name': 'wrighter/python_blogposts', 'size': '11776'}