Coming from R

Almost everything you do to a data frame in the tidyverse has a one-line pandas equivalent, with the verbs renamed, the pipe replaced by a full stop, and columns addressed with square brackets rather than bare names.

The translation table

Task R / tidyverse Python / pandas
Data frame data.frame(), tibble() pd.DataFrame()
Chain operations df %>% f() %>% g() df.f().g()
Filter rows filter(df, prev > 0.4) df[df["prev"] > 0.4]
Add a column mutate(df, x = a * b) df.assign(x=lambda d: d["a"] * d["b"])
Grouped summary df %>% group_by(g) %>% summarise(m = mean(x)) df.groupby("g").agg(m=("x", "mean"))
Sort arrange(df, desc(x)) df.sort_values("x", ascending=False)
Select columns select(df, a, b) df[["a", "b"]]
First rows head(df) df.head()
Structure str(df) df.info()
Vector c(1, 2, 3) [1, 2, 3]
Named list list(a = 1, b = 2) {"a": 1, "b": 2}
Empty NULL None
Missing number NA_real_ np.nan
Index a loop seq_along(x) enumerate(x)
Map over a vector sapply(x, f) [f(i) for i in x]

df["prev"] > 0.4 is a vector of True and False, exactly as in R, and indexing the frame with it keeps the True rows.

A Python list is not an R vector, being untyped and performing no arithmetic, so [1, 2, 3] * 2 repeats the list instead of doubling the numbers. Vector arithmetic needs a NumPy array or a pandas column.

The verbs in pandas

import numpy as np
import pandas as pd

districts = pd.DataFrame({
    "district":   ["Kilombero", "Rufiji", "Ulanga", "Kilosa", "Morogoro"],
    "prevalence": [0.45, 0.31, 0.52, 0.18, 0.27],
    "resistance": [0.30, 0.55, 0.30, 0.70, 0.55],
    "itn_use":    [0.70, 0.65, 0.55, 0.80, 0.60],
})

districts
district prevalence resistance itn_use
0 Kilombero 0.45 0.30 0.70
1 Rufiji 0.31 0.55 0.65
2 Ulanga 0.52 0.30 0.55
3 Kilosa 0.18 0.70 0.80
4 Morogoro 0.27 0.55 0.60

str() becomes .info(), which reports the column names, how many values are present in each, and the type pandas has inferred for each column. The output is printed rather than returned.

districts.info()
<class 'pandas.DataFrame'>
RangeIndex: 5 entries, 0 to 4
Data columns (total 4 columns):
 #   Column      Non-Null Count  Dtype  
---  ------      --------------  -----  
 0   district    5 non-null      str    
 1   prevalence  5 non-null      float64
 2   resistance  5 non-null      float64
 3   itn_use     5 non-null      float64
dtypes: float64(3), str(1)
memory usage: 327.0 bytes

A pipeline becomes a chain of methods hung off the frame itself. In R you would write

districts %>%
  filter(prevalence > 0.25) %>%
  mutate(high_burden = prevalence > 0.40) %>%
  arrange(desc(prevalence)) %>%
  select(district, prevalence, high_burden)

The outer brackets only let the chain span lines.

(
    districts
    [districts["prevalence"] > 0.25]
    .assign(high_burden=lambda d: d["prevalence"] > 0.40)
    .sort_values("prevalence", ascending=False)
    [["district", "prevalence", "high_burden"]]
)
district prevalence high_burden
2 Ulanga 0.52 True
0 Kilombero 0.45 True
1 Rufiji 0.31 False
4 Morogoro 0.27 False

assign takes a lambda, an unnamed one-line function, because the new column is computed from the frame as it stands at that point in the chain, which does not yet have a name. Nothing is mutated in place.

group_by plus summarise becomes groupby().agg(), where each keyword names an output column and takes a (column, function) pair. reset_index() turns the grouping key back into a column. Without it, resistance stays as the frame’s index, which is closer to R’s row names than to a column.

(
    districts
    .groupby("resistance")
    .agg(n=("district", "size"), mean_prev=("prevalence", "mean"))
    .reset_index()
)
resistance n mean_prev
0 0.30 2 0.485
1 0.55 2 0.290
2 0.70 1 0.180

sapply becomes a list comprehension, with the expression first and then what it ranges over. The order is preserved. dn0 is the probability that a mosquito dies on contact with a treated net.

from estimint import calculate_dn0

levels = [0.0, 0.25, 0.50, 0.75]
dn0 = [calculate_dn0(r, py_pbo=0.7).dn0 for r in levels]

pd.DataFrame({"resistance": levels, "dn0": dn0})
resistance dn0
0 0.00 0.50935
1 0.25 0.48930
2 0.50 0.45665
3 0.75 0.39165

enumerate does the job of seq_along, returning the position as well as the value. Python counts from zero.

for i, r in enumerate(levels):
    print(i, r)
0 0.0
1 0.25
2 0.5
3 0.75

Dataclasses

Scenario and EirTarget are dataclasses, and the nearest thing R has is a list() whose named fields are fixed in advance. A misspelled keyword is rejected with a TypeError at construction rather than returning NULL. Seven of the seventeen fields are required (name, res_use, Q0, phi, seasonal, irs and eir_target). The other ten default to zero or None.

from estimint import Scenario, EirTarget

pbo = Scenario(
    name="PBO switch",
    res_use=0.30,
    Q0=0.85,
    phi=0.80,
    seasonal=0.0,
    irs=0.0,
    eir_target=EirTarget(0.45, "prevalence"),
    py_only=0.70,
    itn_future=0.70,
    net_type_future="pyrethroid_pbo",
)

Fields are read back with a full stop, where R would use $, and they nest, so eir_target is itself a dataclass whose own fields are reached the same way. pbo.lsm is already 0.0 although it was never given a value, because defaults are filled in at construction.

print(pbo.name)
print(pbo.res_use, pbo.py_only)
print(pbo.eir_target.input_value, pbo.eir_target.input_mode)
print(pbo.net_type_future, pbo.itn_future, pbo.lsm)
PBO switch
0.3 0.7
0.45 prevalence
pyrethroid_pbo 0.7 0.0

Scenarios as objects

An R controller took parallel vectors of names, resistance levels and net types, with position i of each belonging to scenario i. Drop an element from one vector and every scenario after it shifts too.

Python makes each scenario one Scenario object, and run_scenarios takes a list of them.

setting = dict(
    res_use=0.30, Q0=0.85, phi=0.80, seasonal=0.0, irs=0.0,
    eir_target=EirTarget(0.45, "prevalence"),
    py_only=0.70,
)

candidates = [
    Scenario(name="Like-for-like", **setting, itn_future=0.70,
             net_type_future="pyrethroid_only"),
    Scenario(name="PBO switch", **setting, itn_future=0.70,
             net_type_future="pyrethroid_pbo"),
    Scenario(name="Pyrrole switch", **setting, itn_future=0.70,
             net_type_future="pyrethroid_pyrrole"),
]

len(candidates)
3

setting is a dict of everything the three scenarios share, and **setting unpacks it into each constructor, so the shared arguments are written once and cannot drift apart.

pd.DataFrame([
    {"name": s.name, "net": s.net_type_future, "itn_future": s.itn_future}
    for s in candidates
])
name net itn_future
0 Like-for-like pyrethroid_only 0.7
1 PBO switch pyrethroid_pbo 0.7
2 Pyrrole switch pyrethroid_pyrrole 0.7

Passing candidates to run_scenarios returns one row of results per scenario, in order.

See also

Quickstart runs one scenario end to end, and every field of Scenario in full is in Running scenarios, and A list of scenarios runs several at once.