Unit IV · Basics of Python Programming for Pharmaceutical Sciences (BP101T) · As per PCI B.Pharmacy Syllabus, NEP 2020
Almost every operation in Pandas comes down to working with one of two objects: the Series and the DataFrame. Understanding how these structures are built, indexed, and accessed is the foundation for the rest of Unit IV, and this post walks through both using realistic pharmacokinetic (PK) study data as the running example.

The Pandas Series
A Series is a one-dimensional array of labelled data holding a single data type at a time — integers, floats, text strings, Booleans, or generic Python objects. Picture it as a single column lifted out of a spreadsheet. Every value carries an accompanying index label. Unless told otherwise, Pandas assigns a zero-based integer sequence (0, 1, 2, …) as this index, but it can always be replaced with something more meaningful — patient identifiers, sampling time points, or drug names.
Creating a Series
A Series is built by handing a list, a NumPy array, or a dictionary to the pd.Series() constructor. In the example below, six plasma drug concentration readings (in mg/L) are stored as a Series with the default integer index:
import pandas as pd
# Series of plasma drug concentrations (mg/L) at six time points
concentrations = pd.Series([0.0, 12.5, 18.3, 14.2, 9.8, 5.1])
print(concentrations)
# Output:
# 0 0.0
# 1 12.5
# 2 18.3
# 3 14.2
# 4 9.8
# 5 5.1
# dtype: float64
Assigning a Custom Index
Rather than accepting the automatic integer index, a Series can be built with its own index labels — extremely useful when a value’s position corresponds to something meaningful, such as the exact sampling time in a PK study.
# Step 1: Create the Series with a custom index
data = [0.0, 12.5, 18.3, 14.2, 9.8, 5.1]
time_points = [0.0, 0.5, 1.0, 2.0, 4.0, 8.0]
pk_series = pd.Series(data, index=time_points)
# Step 2: Display the full series
print("Full Series:")
print(pk_series)
# Output:
# Full Series:
# 0.0 0.0
# 0.5 12.5
# 1.0 18.3
# 2.0 14.2
# 4.0 9.8
# 8.0 5.1
# dtype: float64
Accessing Elements by Label
Once a Series carries custom index labels, an individual value can be retrieved directly by that label rather than by counting positions:
# Accessing an element by its index label (Time 0.5)
print(f"Concentration at time 0.5: {pk_series[0.5]}")
# Output: Concentration at time 0.5: 12.5
Useful Series Operations
Two operations come up constantly: finding the peak value, and pulling the index labels back out as a plain Python list.
# Finding the maximum value in the series (e.g. peak concentration, Cmax)
print(f"Maximum Concentration: {pk_series.max()}")
# Output: Maximum Concentration: 18.3
# Converting the index back to an ordinary Python list
print(f"Index list: {pk_series.index.tolist()}")
# Output: Index list: [0.0, 0.5, 1.0, 2.0, 4.0, 8.0]
Semantic Index Labels and Naming a Series
Custom index labels give data real meaning instead of an arbitrary position number. In a PK study, the index can represent actual sampling times in hours, and the Series can be given a descriptive name via the name parameter of pd.Series() — documentation that travels with the data wherever it is printed or passed along.
time_points = [0, 0.5, 1, 2, 4, 8]
pk_series = pd.Series(
[0.0, 12.5, 18.3, 14.2, 9.8, 5.1],
index=time_points,
name="Plasma_Concentration_mg_L"
)
print(pk_series)
# Output:
# 0.0 0.0
# 0.5 12.5
# 1.0 18.3
# 2.0 14.2
# 4.0 9.8
# 8.0 5.1
# Name: Plasma_Concentration_mg_L, dtype: float64
Key Series Attributes and Methods
The table below lists the Series attributes and methods a pharmacy student will use most often, alongside how each applies in a real pharmacy setting.
| Attribute / Method | Description | Pharmacy Example Use |
|---|---|---|
.dtype |
Data type of the Series elements | Confirm concentrations are float64, not object |
.index |
The index labels of the Series | Retrieve time-point labels in a PK dataset |
.values |
NumPy array of underlying data | Pass concentrations to a PK modelling function |
.shape |
Tuple representing dimensionality | Number of time points recorded |
.mean() |
Arithmetic mean of values | Average ADR reporting rate per quarter |
.std() |
Standard deviation | Variability in plasma concentration |
.min() / .max() |
Minimum and maximum values | Cmax identification in PK studies |
.isnull() |
Boolean mask of missing values | Detect missing concentration readings |
The Pandas DataFrame
The DataFrame is the single most important data structure in Pandas — a two-dimensional table with labelled rows and columns, comparable to a spreadsheet or a relational-database table. Every column inside a DataFrame is itself a Series, and all of these column-Series share one common row index. Because a DataFrame can hold columns of different data types side by side, it suits real-world, mixed-type pharmaceutical datasets — a patient-record table might combine numeric lab measurements, categorical text like drug names, and Boolean flags all in one structure.
Creating a DataFrame
DataFrames are most often built by loading an external file (covered in the next topic of this unit), but they can also be constructed directly in code by passing a dictionary of columns to the pd.DataFrame() constructor. The example below builds a small PK dataset for a single patient manually:
import pandas as pd
# Construct a PK dataset for a single patient manually
pk_data = {
"Time_h": [0, 0.5, 1.0, 2.0, 4.0, 8.0, 12.0, 24.0],
"Concentration_mgL": [0.00, 12.5, 18.3, 14.2, 9.8, 5.1, 2.6, 0.4],
"Below_LOQ": [False, False, False, False, False, False, False, False]
}
pk_df = pd.DataFrame(pk_data)
print(pk_df)
The resulting DataFrame displays the three named columns (Time_h, Concentration_mgL, Below_LOQ) alongside an automatically generated integer row index running from 0 to 7. Each column can be reached either through bracket notation, such as pk_df['Time_h'], or through dot notation, such as pk_df.Time_h.
Accessing a Single Column as a Series
print(pk_df['Time_h'])
# Output:
# 0 0.0
# 1 0.5
# 2 1.0
# 3 2.0
# 4 4.0
# 5 8.0
# 6 12.0
# 7 24.0
# Name: Time_h, dtype: float64
Accessing a Single Row with .loc[]
A specific row can be pulled out of a DataFrame using the .loc[] indexer with the row’s index value. Requesting row 2 returns that observation’s readings across all three columns as a Series:
print(pk_df.loc[2])
# Output:
# Time_h 1.0
# Concentration_mgL 18.3
# Below_LOQ False
# Name: 2, dtype: object
Selecting Data with .loc and .iloc
Pandas offers two dedicated indexers for pulling values or subsets out of a DataFrame — one working by label, one by integer position:
- .loc — label-based selection, using index labels or column names.
- .iloc — integer-position-based selection, using zero-based positional order.
The examples below all retrieve the same value — the concentration at Time_h = 2.0 hours, row index 3 — using each indexer, and then pull out whole columns as either a Series or a DataFrame.
# 1) Selecting a single value with .loc (label-based)
value_loc = pk_df.loc[3, 'Concentration_mgL']
print(value_loc) # Output: 14.2
# 2) Selecting a single value with .iloc (position-based)
value_iloc = pk_df.iloc[3, 1]
print(value_iloc) # Output: 14.2
# 3) Selecting an entire column as a Series
conc_series = pk_df["Concentration_mgL"]
print(conc_series)
# 4) Selecting multiple columns as a DataFrame
subset_df = pk_df[['Time_h', 'Concentration_mgL']]
print(subset_df)
Passing a single column name in square brackets returns a Series; passing a list of column names, even a list containing just one name, always returns a DataFrame instead. This distinction trips up many beginners and is worth remembering carefully.
.loc and .iloc, then explain the difference. Remember: .loc takes label(s) — which can include the actual index value and column name — while .iloc takes pure integer positions and works exactly like NumPy array indexing.
Summarizing a DataFrame with info()
The .info() method produces a compact structural overview of a DataFrame — the total row count, the number of non-null (non-missing) entries in each column, and the data type Pandas has inferred for each column. This makes it a valuable first step whenever a new dataset needs its quality checked, and it is examined in far more depth, alongside head(), tail(), and describe(), later in this unit.
pk_df.info()
# Output:
# <class 'pandas.core.frame.DataFrame'>
# RangeIndex: 8 entries, 0 to 7
# Data columns (total 3 columns):
# # Column Non-Null Count Dtype
# --- ------ -------------- -----
# 0 Time_h 8 non-null float64
# 1 Concentration_mgL 8 non-null float64
# 2 Below_LOQ 8 non-null bool
# dtypes: bool(1), float64(2)
# memory usage: 268.0 bytes
Where Real-World DataFrame Data Comes From
In actual pharmaceutical work, data is rarely typed into Python by hand the way it is in the small worked examples above. Datasets are far more commonly pulled from clinical data management systems, laboratory information management systems (LIMS), pharmacovigilance databases, or regulatory submission files, and these are typically exported as CSV (comma-separated values) files or Excel (.xlsx) workbooks. Pandas supplies dedicated functions that load such files straight into a DataFrame, automatically handling the parsing of each column’s data type, the identification of header rows, and the assignment of index columns along the way — exactly the subject of the next topic in this unit.
Frequently Asked Questions
What is the main difference between a Series and a DataFrame?
A Series is a one-dimensional, single-typed structure, essentially one labelled column of data. A DataFrame is a two-dimensional table made up of one or more Series sharing a common index, and it can hold different data types in different columns simultaneously.
When should I use .loc instead of .iloc, and vice versa?
Use .loc when you know the actual index label or column name you want (for example, the row labelled by a specific sampling time or subject ID). Use .iloc when you want to select by pure position — the third row or the second column — regardless of what its label happens to be.
Why does selecting a single column with double square brackets return a DataFrame instead of a Series?
A single set of brackets with one column name, such as df['col'], returns that column as a Series. Double brackets, such as df[['col']], actually pass a one-item list of column names, and passing any list — even a list of one — always returns a DataFrame rather than a Series.
Summary
The Series and the DataFrame are the two foundational data structures in Pandas. A Series is a one-dimensional, labelled, single-type array — best thought of as a single column — while a DataFrame is a two-dimensional table of one or more Series sharing a common row index, capable of holding mixed data types across its columns. Values can be pulled out of either structure using label-based selection with .loc or position-based selection with .iloc, and the .info() method offers a fast first look at a DataFrame’s structure. Mastering these two structures and their indexers is essential groundwork before moving on to reading real pharmaceutical CSV and Excel files in the next topic.
References
- Pharmacy Council of India (PCI) — B.Pharm Regulations, NEP 2020, BP101T Syllabus
- Python Software Foundation — Official Python Documentation, docs.python.org
- Pandas Development Team — pandas.Series and pandas.DataFrame API Reference, pandas.pydata.org
- Pandas Development Team — Indexing and Selecting Data (loc / iloc), pandas.pydata.org
