Chapter 23 — Introduction to numpy and pandas: Smart Tables for Field Data

Series I2 — Data and the Internet

NoteIn this chapter, you will:
  • Use numpy arrays: one formula for thousands of numbers at once
  • Create and read pandas DataFrames, tables with column names
  • Read and write CSV, Excel, and JSON
  • Filter rows and summarise data with groupby
  • Merge five sectors, weather, and prices: Running Project I2

All scripts for this chapter are in the kode/ch23/ folder. On the web version, you can also download them all at once (zip).

Imagine a field notebook with thousands of rows. Calculating the mean diameter with for is possible, but slow and long. numpy is like a uniform sack: everything inside is one kind (say, decimal numbers), so it can be weighed, multiplied, and summed all at once. pandas is a labelled worksheet (like Excel) built on numpy: every column has a name, every row has a number, and you can filter and summarise it in one or two lines of code. (Installing: pip install numpy pandas; both are already in this project’s environment.)

Anatomy of a DataFrame: the index on the left, column names on top, one column is one Series, one row is one record.

numpy: arrays

01_array_basics.py
# Chapter 23 - numpy arrays: fast, uniform lists of numbers
import numpy as np

diameter = np.array([32.5, 45.2, 28.0, 19.8, 36.1, 51.5])
print(diameter)
print("type:", diameter.dtype, "| shape:", diameter.shape, "| count:", diameter.size)

print(np.arange(0, 10, 2))               # like range(), but produces an array
print(np.linspace(0, 1, 5))              # 5 evenly spaced numbers between 0 and 1
print(np.zeros((2, 3)))                  # a table of 2 rows x 3 columns filled with zeros

table = np.array([[32.5, 18.0], [45.2, 25.5], [28.0, 15.0]])   # diameter, height
print(table.shape, "->", table.shape[0], "rows,", table.shape[1], "columns")
print(table[1, 0], "|", table[:, 1], "|", table[0])             # [row, column]
[32.5 45.2 28.  19.8 36.1 51.5]
type: float64 | shape: (6,) | count: 6
[0 2 4 6 8]
[0.   0.25 0.5  0.75 1.  ]
[[0. 0. 0.]
 [0. 0. 0.]]
(3, 2) -> 3 rows, 2 columns
45.2 | [18.  25.5 15. ] | [32.5 18. ]
  • np.array([...]) makes an array. dtype: float64 means everything inside is a 64-bit decimal; shape: (6,) means one dimension, six elements.
  • np.arange is like range (Chapter 8); np.linspace(0, 1, 5) divides 0 to 1 evenly into 5 points; np.zeros((2, 3)) makes a table of zeros with 2 rows x 3 columns.
  • In a two-dimensional array you write [row, column]: table[1, 0] = 45.2 (row 1, column 0), table[:, 1] = the whole of column 1 (height).

Whole-array operations

02_array_operations.py
# Chapter 23 - Whole-array operations (vectors), logical selection, and axes
import numpy as np

diameter_cm = np.array([32.5, 45.2, 28.0, 19.8, 36.1, 51.5])

radius_m = diameter_cm / 2 / 100         # one formula for ALL elements, no loop
print("radius (m):", radius_m)
area_m2 = 3.14159 * radius_m ** 2
print("cross-section area (m2):", area_m2.round(3))

big = diameter_cm >= 30                  # the result is an array of True/False
print("big?     ", big)
print("the big ones:", diameter_cm[big])  # selecting with a True/False mask
print("mean:", diameter_cm.mean().round(2), "| largest:", diameter_cm.max(), "| standard deviation:",
      diameter_cm.std().round(2))

table = np.array([[32.5, 18.0], [45.2, 25.5], [28.0, 15.0]])
print("mean of each column (axis=0):", table.mean(axis=0))
print("sum of each row     (axis=1):", table.sum(axis=1))
radius (m): [0.1625 0.226  0.14   0.099  0.1805 0.2575]
cross-section area (m2): [0.083 0.16  0.062 0.031 0.102 0.208]
big?      [ True  True False False  True  True]
the big ones: [32.5 45.2 36.1 51.5]
mean: 35.52 | largest: 51.5 | standard deviation: 10.51
mean of each column (axis=0): [35.23333333 19.5       ]
sum of each row     (axis=1): [50.5 70.7 43. ]

There is no for: diameter_cm / 2 / 100 is carried out on all six elements at once (vectorisation). The comparison diameter_cm >= 30 produces an array of True/False, and that array can be used as a mask to select elements (diameter_cm[big]).

axis sets the direction: axis=0 summarises downward per column (mean diameter 35.23 and height 19.5), axis=1 summarises sideways per row (50.5; 70.7; 43.0).

WarningWARNING

The numpy standard deviation (std()) is 10.51, yet pandas describe() in the DataFrame section below shows 11.51 for the same data. This is not a miscalculation: numpy divides by n (the population) by default, pandas divides by n-1 (a sample). Series I3 explains why; for now, remember that two libraries can have different defaults.

pandas: DataFrame

03_dataframe_basics.py
# Chapter 23 - DataFrame: a table with column names
import pandas as pd

trees = pd.DataFrame({
    "species": ["Teak", "Meranti", "Mahogany", "Sengon", "Teak", "Meranti"],
    "plot": ["A", "A", "B", "C", "B", "C"],
    "diameter_cm": [32.5, 45.2, 28.0, 19.8, 36.1, 51.5],
    "height_m": [18.0, 25.5, 15.0, 12.0, 20.5, 28.0],
    "healthy": [True, False, True, True, False, True],
})
print(trees)
print()
print("shape (rows, columns):", trees.shape)
print(trees.dtypes)
print()
print(trees["diameter_cm"].mean())        # one column = a Series
print(trees.describe().round(2))          # statistical summary of the numeric columns
    species plot  diameter_cm  height_m  healthy
0      Teak    A         32.5      18.0     True
1   Meranti    A         45.2      25.5    False
2  Mahogany    B         28.0      15.0     True
3    Sengon    C         19.8      12.0     True
4      Teak    B         36.1      20.5    False
5   Meranti    C         51.5      28.0     True

shape (rows, columns): (6, 5)
species            str
plot               str
diameter_cm    float64
height_m       float64
healthy           bool
dtype: object

35.516666666666666
       diameter_cm  height_m
count         6.00      6.00
mean         35.52     19.83
std          11.51      6.12
min          19.80     12.00
25%          29.12     15.75
50%          34.30     19.25
75%          42.92     24.25
max          51.50     28.00

The columns of a DataFrame may be of different kinds: text (str), decimal (float64), true/false (bool). A single column, such as trees["diameter_cm"], is called a Series. describe() summarises the numeric columns: the count, mean (35.52), smallest (19.8), quartiles, largest (51.5).

Reading and writing files

04_read_write.py
# Chapter 23 - Writing and reading CSV, Excel, and JSON
from pathlib import Path
import pandas as pd

tmp = Path(__file__).parent / "_tmp"
tmp.mkdir(exist_ok=True)

trees = pd.DataFrame({
    "species": ["Teak", "Meranti", "Mahogany"],
    "diameter_cm": [32.5, 45.2, 28.0],
    "healthy": [True, False, True],
})

trees.to_csv(tmp / "trees.csv", index=False)          # index=False: do not write row numbers
print((tmp / "trees.csv").read_text(encoding="utf-8"))

from_csv = pd.read_csv(tmp / "trees.csv")
print(from_csv.equals(trees), "<- CSV came back identical")

trees.to_excel(tmp / "trees.xlsx", index=False)       # uses the openpyxl library
from_excel = pd.read_excel(tmp / "trees.xlsx")
print(from_excel.equals(trees), "<- Excel came back identical")

trees.to_json(tmp / "trees.json", orient="records", force_ascii=False)
print((tmp / "trees.json").read_text(encoding="utf-8"))
species,diameter_cm,healthy
Teak,32.5,True
Meranti,45.2,False
Mahogany,28.0,True

True <- CSV came back identical
True <- Excel came back identical
[{"species":"Teak","diameter_cm":32.5,"healthy":true},{"species":"Meranti","diameter_cm":45.2,"healthy":false},{"species":"Mahogany","diameter_cm":28.0,"healthy":true}]

to_csv/read_csv, to_excel/read_excel (needs openpyxl), and to_json/read_json make exchanging data with other programs as easy as one line. index=False prevents the row numbers from being written as a column. The equals(...) results are True: the data read back is exactly the same as the original.

Selecting and filtering

05_select_and_filter.py
# Chapter 23 - Selecting columns/rows and filtering
import pandas as pd

trees = pd.DataFrame({
    "species": ["Teak", "Meranti", "Mahogany", "Sengon", "Teak", "Meranti"],
    "plot": ["A", "A", "B", "C", "B", "C"],
    "diameter_cm": [32.5, 45.2, 28.0, 19.8, 36.1, 51.5],
    "healthy": [True, False, True, True, False, True],
})

print(trees[["species", "diameter_cm"]].head(2))          # several columns
print(trees.iloc[0])                                       # row number 0 by position
print(trees.loc[2, "species"])                             # row labelled 2, column "species"

big = trees[trees["diameter_cm"] >= 30]                    # filter with a condition
print(big)

sick_big = trees[(trees["diameter_cm"] >= 30) & (~trees["healthy"])]    # & = AND, ~ = NOT
print(sick_big)
print(trees.query("plot == 'A' or species == 'Teak'"))     # another way, reads like a sentence
print(trees.sort_values("diameter_cm", ascending=False).head(3))
   species  diameter_cm
0     Teak         32.5
1  Meranti         45.2
species        Teak
plot              A
diameter_cm    32.5
healthy        True
Name: 0, dtype: object
Mahogany
   species plot  diameter_cm  healthy
0     Teak    A         32.5     True
1  Meranti    A         45.2    False
4     Teak    B         36.1    False
5  Meranti    C         51.5     True
   species plot  diameter_cm  healthy
1  Meranti    A         45.2    False
4     Teak    B         36.1    False
   species plot  diameter_cm  healthy
0     Teak    A         32.5     True
1  Meranti    A         45.2    False
4     Teak    B         36.1    False
   species plot  diameter_cm  healthy
5  Meranti    C         51.5     True
1  Meranti    A         45.2    False
4     Teak    B         36.1    False
  • df[["a", "b"]] selects columns; iloc selects by position, loc by label.
  • df[df["diameter_cm"] >= 30] filters rows with a condition. To combine conditions use & (and), | (or), ~ (not), with each condition in brackets.
  • query("plot == 'A' or species == 'Teak'") is a way that reads more like a sentence.
  • sort_values(..., ascending=False) sorts from the largest.

Aggregation: summarising per group

06_aggregation.py
# Chapter 23 - Aggregation: groupby, value_counts, pivot_table
import pandas as pd

trees = pd.DataFrame({
    "species": ["Teak", "Meranti", "Mahogany", "Sengon", "Teak", "Meranti"],
    "plot": ["A", "A", "B", "C", "B", "C"],
    "diameter_cm": [32.5, 45.2, 28.0, 19.8, 36.1, 51.5],
    "height_m": [18.0, 25.5, 15.0, 12.0, 20.5, 28.0],
})

print(trees["species"].value_counts())                     # how many trees per species
print(trees.groupby("species")["diameter_cm"].mean().round(2))

summary = trees.groupby("plot").agg(
    count=("species", "count"),
    mean_diameter=("diameter_cm", "mean"),
    max_height=("height_m", "max"),
)
print(summary.round(2))

print(trees.pivot_table(index="plot", columns="species", values="diameter_cm", aggfunc="mean"))
species
Teak        2
Meranti     2
Mahogany    1
Sengon      1
Name: count, dtype: int64
species
Mahogany    28.00
Meranti     48.35
Sengon      19.80
Teak        34.30
Name: diameter_cm, dtype: float64
      count  mean_diameter  max_height
plot                                  
A         2          38.85        25.5
B         2          32.05        20.5
C         2          35.65        28.0
species  Mahogany  Meranti  Sengon  Teak
plot                                    
A             NaN     45.2     NaN  32.5
B            28.0      NaN     NaN  36.1
C             NaN     51.5    19.8   NaN

groupby works like sorting sacks by species and then weighing each pile: the split, compute, combine pattern. The mean diameter of Teak is 34.30 (from 32.5 and 36.1), of Meranti 48.35. agg allows several summaries at once with result columns you name yourself. pivot_table makes a cross table of plot x species; cells without data are filled with NaN (“not a number” = empty).

Joining tables and missing data

07_merge_and_missing.py
# Chapter 23 - Joining tables (merge, concat) and missing data (NaN)
import pandas as pd

harvest = pd.DataFrame({"commodity": ["coffee", "rice", "catfish"], "yield_kg": [245.5, 3100.0, 432.4]})
prices = pd.DataFrame({"commodity": ["coffee", "rice", "maize"], "price_per_kg": [58000, 6200, 5100]})

# how="left": every harvest row is kept; rows without a price partner are filled with NaN
joined = harvest.merge(prices, on="commodity", how="left")
print(joined)
print("Rows with empty price:", joined["price_per_kg"].isna().sum())

joined["income"] = joined["yield_kg"] * joined["price_per_kg"]
print(joined)
print(joined.fillna({"price_per_kg": 0, "income": 0}))      # fill the empties with 0
print(joined.dropna())                                       # drop rows that contain empties

extra = pd.DataFrame({"commodity": ["cattle"], "yield_kg": [1151.5]})
print(pd.concat([harvest, extra], ignore_index=True))        # stacking rows downward
  commodity  yield_kg  price_per_kg
0    coffee     245.5       58000.0
1      rice    3100.0        6200.0
2   catfish     432.4           NaN
Rows with empty price: 1
  commodity  yield_kg  price_per_kg      income
0    coffee     245.5       58000.0  14239000.0
1      rice    3100.0        6200.0  19220000.0
2   catfish     432.4           NaN         NaN
  commodity  yield_kg  price_per_kg      income
0    coffee     245.5       58000.0  14239000.0
1      rice    3100.0        6200.0  19220000.0
2   catfish     432.4           0.0         0.0
  commodity  yield_kg  price_per_kg      income
0    coffee     245.5       58000.0  14239000.0
1      rice    3100.0        6200.0  19220000.0
  commodity  yield_kg
0    coffee     245.5
1      rice    3100.0
2   catfish     432.4
3    cattle    1151.5

merge joins two tables through a shared column, like JOIN in a database (Chapter 24). With how="left", all rows of harvest are kept; catfish has no price so it is NaN, and its income is empty too. There are three ways to handle empties: leave them, fillna (fill a replacement value), or dropna (drop the row). Choose according to what the data means: filling the catfish price with 0 means “free”, which is not necessarily true.

TipTIP

After a merge, always check isna().sum(). Unpaired rows are almost always a sign of a name that does not match (for example coffee versus Coffee) or of data that simply does not exist yet.

Running Project I2: merging five sectors, weather, and prices

Step 3: ten harvest records from five sectors (forestry, plantation, cropland, fisheries, livestock), the Bogor weather, and the prices are merged into one table. (Fictional data; copied into the script so it stands on its own, with the same shape as the Chapter 21 results.)

08_project_merge.py
# Chapter 23 - Running project I2, step 3: merging five sectors, weather, and prices
import pandas as pd

# Five-sector data (fictional). Copied here so the script stands on its own.
harvest = pd.DataFrame(
    [("2026-01-01", "Forestry", "Plot A", "teak", 1200.0),
     ("2026-01-01", "Plantation", "Block A", "coffee", 245.5),
     ("2026-01-02", "Plantation", "Block B", "cocoa", 198.5),
     ("2026-01-02", "Cropland", "Paddy 1", "rice", 3100.0),
     ("2026-01-03", "Cropland", "Paddy 2", "maize", 1850.0),
     ("2026-01-03", "Fisheries", "Pond A", "catfish", 432.4),
     ("2026-01-04", "Livestock", "Barn 1", "cattle", 1151.5),
     ("2026-01-04", "Plantation", "Block C", "coffee", 310.0),
     ("2026-01-05", "Cropland", "Paddy 1", "rice", 2950.0),
     ("2026-01-05", "Plantation", "Block D", "cocoa", 276.0)],
    columns=["date", "sector", "unit", "commodity", "yield_kg"],
)
# Bogor weather and prices: same shape as the data from the mock server in Chapter 21 (fictional)
weather = pd.DataFrame({
    "date": ["2026-01-01", "2026-01-02", "2026-01-03", "2026-01-04", "2026-01-05"],
    "max_temp": [30.1, 31.4, 29.8, 28.9, 30.6],
    "rain_mm": [12.5, 0.0, 8.2, 25.4, 3.1],
})
prices = pd.DataFrame(
    [(d, c, p) for c, values in {
        "rice": [6200, 6250, 6180, 6300, 6270],
        "maize": [5100, 5150, 5200, 5120, 5180],
        "coffee": [58000, 58500, 59000, 57500, 58200],
        "cocoa": [62000, 61500, 62500, 63000, 62800]}.items()
     for d, p in zip(weather["date"], values)],
    columns=["date", "commodity", "price_per_kg"],
)

merged = (harvest
          .merge(weather, on="date", how="left")
          .merge(prices, on=["date", "commodity"], how="left"))
merged["income_rp"] = merged["yield_kg"] * merged["price_per_kg"]

print(merged.drop(columns=["date"]).to_string(index=False))
print()
print("Rows without a price (forestry, fish, cattle):", int(merged["price_per_kg"].isna().sum()))
per_sector = merged.groupby("sector").agg(yield_kg=("yield_kg", "sum"), income_rp=("income_rp", "sum"))
print(per_sector.round(0))
    sector    unit commodity  yield_kg  max_temp  rain_mm  price_per_kg  income_rp
  Forestry  Plot A      teak    1200.0      30.1     12.5           NaN        NaN
Plantation Block A    coffee     245.5      30.1     12.5       58000.0 14239000.0
Plantation Block B     cocoa     198.5      31.4      0.0       61500.0 12207750.0
  Cropland Paddy 1      rice    3100.0      31.4      0.0        6250.0 19375000.0
  Cropland Paddy 2     maize    1850.0      29.8      8.2        5200.0  9620000.0
 Fisheries  Pond A   catfish     432.4      29.8      8.2           NaN        NaN
 Livestock  Barn 1    cattle    1151.5      28.9     25.4           NaN        NaN
Plantation Block C    coffee     310.0      28.9     25.4       57500.0 17825000.0
  Cropland Paddy 1      rice    2950.0      30.6      3.1        6270.0 18496500.0
Plantation Block D     cocoa     276.0      30.6      3.1       62800.0 17332800.0

Rows without a price (forestry, fish, cattle): 3
            yield_kg   income_rp
sector                          
Cropland      7900.0  47491500.0
Fisheries      432.0         0.0
Forestry      1200.0         0.0
Livestock     1152.0         0.0
Plantation    1030.0  61604550.0

Trace the result:

  • Each harvest record receives the temperature and rain of the same day (merge on date) and the price of the same commodity and day (merge on two columns).
  • 3 rows without a price: teak timber, catfish, cattle, because the price API only holds rice, maize, coffee, cocoa. income_rp for those rows is NaN.
  • Summary per sector: Plantation 1030.0 kg with income Rp 61,604,550; Cropland 7900.0 kg with Rp 47,491,500. Forestry, Fisheries, and Livestock show 0 income only because sum ignores NaN, not because their value is really zero. Data limits like this must be stated frankly when reporting.

Exercises

  1. From the array np.array([480, 500, 510, 520, 530]), compute the mean weight and select those heavier than 505 with a mask.
  2. Make a fish DataFrame with the columns pond, species, count, avg_weight_g, then compute the biomass of each row (count * avg_weight_g / 1000) as a new column.
  3. In 06_aggregation.py, add an aggregation of the number of trees and the smallest diameter per species.
  4. Challenge: in 08_project_merge.py, compute the mean income per sector only from rows that have a price, and compute what percentage of rows have no price.

Chapter Summary

  • numpy array: one kind of content, operations all at once without for; axis=0 per column, axis=1 per row.
  • DataFrame = a labelled table; one column = a Series. head, describe, dtypes, shape help you get to know it.
  • Read/write: read_csv, read_excel, to_json, and friends; index=False avoids the row-number column.
  • Filter with conditions (&, |, ~, each in brackets) or query; sort with sort_values.
  • groupby + agg summarises per group; merge joins tables; beware of NaN after merging.
  • Libraries can have different defaults (numpy vs pandas standard deviation).

Next, in Chapter 24: that merged data vanishes when the program is closed. We store it in a database: SQLite, SQLAlchemy, and PostgreSQL.