import polars as pl
import svy
SAMPLICS_DATA = "https://raw.githubusercontent.com/samplics-org/samplics/v0.6.1/src/samplics/datasets/data/"
nhanes = pl.read_csv(SAMPLICS_DATA + "nhanes2.csv")
design = svy.Design(stratum="stratid", psu="psuid", wgt="finalwgt")
sample = svy.Sample(data=nhanes, design=design)
print(sample.estimation.mean("zinc", drop_nulls=True))Migrating from samplics to svy
The svy code for every samplics class, checked on the same data
samplics, samplics to svy, migrate from samplics, samplics TaylorEstimator equivalent, samplics ReplicateEstimator equivalent, samplics SampleWeight equivalent, samplics SampleSelection equivalent, samplics SampleSize equivalent, samplics Tabulation CrossTabulation equivalent, samplics SurveyGLM equivalent, samplics EblupAreaModel EblupUnitModel equivalent, samplics archived replacement, survey analysis Python
samplics is archived. svy is its successor, written by the same author, and covers everything samplics did. This page gives the svy code for each public samplics API. We ran every pair on samplics’ own bundled datasets and compared point estimates, standard errors, confidence intervals and degrees of freedom; the comparison lists the results. Where the numbers differ, the default differences section says why and which setting reproduces samplics.
One Sample instead of many estimators
In samplics, every estimator takes the weights, strata and PSUs as arrays on every call. In svy, the design is declared once on a Sample, and every method reads it from there.
samplics:
from samplics import PopParam, TaylorEstimator, load_nhanes2
df = load_nhanes2()["data"]
zinc_mean = TaylorEstimator(PopParam.mean)
zinc_mean.estimate(
y=df["zinc"],
samp_weight=df["finalwgt"],
stratum=df["stratid"],
psu=df["psuid"],
remove_nan=True,
)
print(zinc_mean)svy:
The examples on this page read samplics’ CSV files from its v0.6.1 tag, so you can run them without installing samplics. With samplics installed, pl.from_pandas(load_nhanes2()["data"]) gives the same frame.
Other changes that apply everywhere:
- svy works on Polars data frames. Pass a pandas frame through
pl.from_pandas(). - Domains are
by=(one estimate per level) andwhere=(one subpopulation). Both keep the full design for the variance. - Results print as tables and export with
.to_polars(). - Methods that change weights return a new
Samplewith the new weight column and an updated design.
Lookup table
| samplics | svy | Tutorial |
|---|---|---|
TaylorEstimator(PopParam.mean) |
sample.estimation.mean(y) |
Estimation |
TaylorEstimator(PopParam.total) |
sample.estimation.total(y) |
Estimation |
TaylorEstimator(PopParam.prop) |
sample.estimation.prop(y) |
Estimation |
TaylorEstimator(PopParam.ratio) |
sample.estimation.ratio(y, x=) |
Estimation |
TaylorEstimator(PopParam.median) |
sample.estimation.median(y) |
Estimation |
estimate(domain=) |
by= or where= |
Estimation |
estimate(fpc=) |
svy.Design(pop_size=) |
Estimation |
SinglePSUEst |
svy.Design(singleton=), svy.Singleton |
Singleton PSUs |
ReplicateEstimator |
sample.estimation.*(method="replication") |
Estimation |
ReplicateWeight |
sample.weighting.create_brr_wgts(), create_jk_wgts(), create_bs_wgts() |
Replicate weights |
RepMethod |
svy.BrrWgts, svy.JackknifeWgts, svy.BootstrapWgts |
Replicate weights |
SampleWeight().adjust() |
sample.weighting.adjust() |
Weighting |
SampleWeight().normalize() |
sample.weighting.normalize() |
Weighting |
SampleWeight().poststratify() |
sample.weighting.poststratify() |
Weighting |
SampleWeight().calibrate(), calib_covariates() |
sample.weighting.calibrate() with svy.Cat |
Weighting |
SampleWeight().rake() |
sample.weighting.rake() |
Weighting |
SampleWeight().calculate_deff_weight() |
sample.deff_w |
|
SampleSelection, SelectMethod |
sample.sampling.srs(), pps_sys(), pps_brewer(), pps_rs(), pps_murphy(), pps_wr() |
Selection |
SampleSize, SizeMethod |
svy.SampleSize().estimate_prop(), estimate_mean() |
Planning |
SampleSizeMeanTwoSample, SampleSizePropTwoSample |
svy.SampleSize().compare_means(), compare_props() |
Planning |
allocate |
svy.SampleSize().allocate() |
Planning |
Tabulation |
sample.categorical.tabulate(rowvar) |
Categorical |
CrossTabulation |
sample.categorical.tabulate(rowvar, colvar) |
Categorical |
Ttest |
sample.categorical.ttest() |
Categorical |
SurveyGLM, ModelType |
sample.glm.fit(family=) |
GLM |
EblupAreaModel |
svy_sae.AreaLevel |
svy-sae |
EblupUnitModel, EbUnitModel, EllUnitModel |
svy_sae.UnitLevel |
svy-sae |
SamplicsError and subclasses |
svy.SvyError and subclasses |
Errors |
load_* |
pl.read_csv() on the samplics files, or svy.datasets.load() |
Datasets |
Side by side
Sample size
samplics:
from samplics import SampleSize, SampleSizePropTwoSample, PopParam, SizeMethod, allocate
ss = SampleSize(param=PopParam.prop, method=SizeMethod.fleiss)
ss.calculate(target=0.95, half_ci=0.06)
ss = SampleSize(param=PopParam.mean)
ss.calculate(half_ci=1, sigma=2, pop_size=200)
two = SampleSizePropTwoSample(two_sides=False)
two.calculate(prop_1=0.65, prop_2=0.85, delta=-0.10)
N_h = {"East": 4345, "North": 1385, "South": 2725, "West": 6545}
S_h = {"East": 12.0, "North": 20.0, "South": 15.0, "West": 9.0}
allocate("optimum_mean", stratum=list(N_h), pop_size=N_h, rate=0.005, stddev=S_h)svy:
print(svy.SampleSize().estimate_prop(p=0.95, moe=0.06, method="fleiss").n)
print(svy.SampleSize().estimate_mean(sigma=2, moe=1, pop_size=200).n)
print(svy.SampleSize().compare_props(p1=0.65, p2=0.85, two_sides=False, delta=-0.10).n)
N_h = {"East": 4345, "North": 1385, "South": 2725, "West": 6545}
S_h = {"East": 12.0, "North": 20.0, "South": 15.0, "West": 9.0}
print(svy.SampleSize().allocate(900, pop_size=N_h, method="neyman", sigma=S_h).n)Stratified sizes take dictionaries for p, sigma or moe, in place of samplics’ strat=True. allocate also does "equal" and "proportional" (the default) allocation, and square-root allocation with power=0.5.
Selection
samplics:
from samplics import SampleSelection, SelectMethod, load_psu_frame
frame = load_psu_frame()["data"]
n = {"East": 3, "West": 2, "North": 2, "South": 3}
pps = SampleSelection(method=SelectMethod.pps_sys, strat=True)
flag, hits, probs = pps.select(
samp_unit=frame["cluster"],
samp_size=n,
stratum=frame["region"],
mos=frame["number_households_census"],
)svy:
frame = pl.read_csv(SAMPLICS_DATA + "psu_frame.csv")
n = {"East": 3, "West": 2, "North": 2, "South": 3}
frame_design = svy.Design(stratum="region", psu="cluster", mos="number_households_census")
psu_sample = svy.Sample(frame, frame_design).sampling.pps_sys(n=n, rstate=1)
print(
psu_sample.data.select(
"cluster", "region", "svy_prob_selection", "svy_sample_weight", "svy_number_of_hits"
)
)svy returns the selected units only, with the selection probability and the weight. samplics returns arrays over the whole frame. The other methods follow the same pattern: srs() (with wr=True for replacement), pps_brewer(), pps_rs(), pps_murphy() and pps_wr(). For a second stage, sampling.add_stage(listing) links the units to the selected PSUs, as in the selection tutorial.
The draws themselves never match samplics, even with the same seed: the random number generators differ. For selection without replacement, the probabilities and weights of the selected units match.
Weighting
samplics:
from samplics import SampleWeight
w = df["design_wgt"]
nr_wgt = SampleWeight().adjust(w, df["region"], df["status"])
ps_wgt = SampleWeight().poststratify(w, control=census, domain=df["region"])
x, totals = SampleWeight().calib_covariates(df, x_cat=["educ"], x_cont=["poverty"])
totals.update({"High": 1400, "Low": 5800, "Medium": 7300, "poverty": 4700})
cal_wgt = SampleWeight().calibrate(w, x, totals)
rk_wgt = SampleWeight().rake(
w, {"educ": df["educ"], "region": df["region"]}, control=margins, tol=1e-12, ctrl_tol=1e-12
)svy, on samplics’ two-stage sample with a simulated response status, education and poverty status:
import numpy as np
households = (
pl.read_csv(SAMPLICS_DATA + "psu_sample.csv")
.drop("")
.join(pl.read_csv(SAMPLICS_DATA + "ssu_sample.csv").drop(""), on="cluster")
.with_columns(design_wgt=1 / (pl.col("psu_prob") * pl.col("ssu_prob")))
)
rng = np.random.default_rng(7)
size = households.height
households = households.with_columns(
status=pl.Series(rng.choice(["in", "rr", "nr", "uk"], size=size, p=(0.1, 0.7, 0.15, 0.05))),
poverty=pl.Series(rng.choice([0, 1], size=size, p=(0.7, 0.3))),
educ=pl.Series(rng.choice(["Low", "Medium", "High"], size=size, p=(0.4, 0.5, 0.1))),
)
hh = svy.Sample(data=households, design=svy.Design(wgt="design_wgt"))
census = {"East": 3700, "North": 1500, "South": 2800, "West": 6500}
margins = {"educ": {"High": 1400, "Low": 5800, "Medium": 7300}, "region": census}
nr = hh.weighting.adjust("status", cells="region", respondents_only=False)
ps = hh.weighting.poststratify(census, cells="region")
cal = hh.weighting.calibrate(
controls={svy.Cat("educ"): {"High": 1400, "Low": 5800, "Medium": 7300}, "poverty": 4700}
)
rk = hh.weighting.rake(controls=margins, tol=1e-12)
print(
pl.concat(
[
hh.data.select("household", "region", "status", "design_wgt"),
nr.data.select("nr_wgt"),
ps.data.select("ps_wgt"),
cal.data.select("calib_wgt"),
rk.data.select("rk_wgt"),
],
how="horizontal",
).head()
)adjust() keeps only respondents by default. respondents_only=False keeps every row, as samplics did. Calibration by domain is calibrate(controls={domain: {...}}, by="region"), and normalization is normalize() or normalize(1000), as in samplics.
Replicate weights
samplics:
from samplics import ReplicateEstimator, RepMethod, PopParam, load_nhanes2brr
d = load_nhanes2brr()["data"]
height = ReplicateEstimator(RepMethod.brr, PopParam.mean).estimate(
y=d["height"], samp_weight=d["finalwgt"], rep_weights=d.filter(like="brr_")
)svy:
brr_data = pl.read_csv(SAMPLICS_DATA + "nhanes2brr_subset.csv")
brr_design = svy.Design(
wgt="finalwgt",
rep_wgts=svy.BrrWgts(prefix="brr_", n_reps=32, df=16),
)
brr_sample = svy.Sample(data=brr_data, design=brr_design)
print(brr_sample.estimation.mean("height", method="replication"))df=16 reproduces samplics’ default of half the number of replicates; svy’s own default is n_reps - 1. Use the value the data producer documents. For jackknife columns, use svy.JackknifeWgts(prefix="jkw_", n_reps=62, scale=0.5) and set scale to the documented coefficient. samplics’ conservative=True is variance_center="estimate", and fay_coef= is set on svy.BrrWgts.
To create replicate weights from the design, as ReplicateWeight().replicate() did:
jk_sample = sample.weighting.create_jk_wgts(rep_prefix="jk")
print(jk_sample.estimation.prop("highbp", method="replication"))create_brr_wgts() and create_bs_wgts() work the same way, and the replicate weights tutorial covers each method.
Estimation
samplics:
from samplics import PopParam, TaylorEstimator
design = dict(samp_weight=df["finalwgt"], stratum=df["stratid"], psu=df["psuid"])
prop = TaylorEstimator(PopParam.prop)
prop.estimate(y=df["highbp"], domain=df["race"], **design)
ratio = TaylorEstimator(PopParam.ratio)
ratio.estimate(y=df["diabetes"], x=df["highbp"], remove_nan=True, **design)
fpc = {h: 1 - 2 / (10 + h) for h in df["stratid"].unique()}
total = TaylorEstimator(PopParam.total)
total.estimate(y=df["zinc"], fpc=fpc, remove_nan=True, **design)svy:
print(sample.estimation.prop("highbp", by="race"))
print(sample.estimation.ratio("diabetes", x="highbp", drop_nulls=True))
fpc_sample = svy.Sample(
data=nhanes.with_columns(N_h=10 + pl.col("stratid")),
design=svy.Design(stratum="stratid", psu="psuid", wgt="finalwgt", pop_size="N_h"),
)
print(fpc_sample.estimation.total("zinc", drop_nulls=True))samplics’ fpc takes the factor \(1 - n_h/N_h\). svy’s pop_size takes the population count \(N_h\) and computes the factor.
deff="wor" or deff="wr" returns design effects; samplics accepted deff=True but never computed them. mean(y, as_factor=True) and total(y, as_factor=True) work as in samplics.
Tabulation and t-tests
samplics:
from samplics import CrossTabulation, PopParam, Tabulation, Ttest
tab = Tabulation(PopParam.prop)
tab.tabulate(df["race"], **design)
crosstab = CrossTabulation(PopParam.prop)
crosstab.tabulate(df[["race", "highbp"]], **design)
z = df.dropna(subset=["zinc"])
ttest = Ttest("one-sample")
ttest.compare(y=z["zinc"], known_mean=87, samp_weight=z["finalwgt"], stratum=z["stratid"], psu=z["psuid"])svy:
print(sample.categorical.tabulate("race"))
crosstab = sample.categorical.tabulate("race", "highbp")
print(crosstab)
print(crosstab.stats)
print(sample.categorical.ttest("zinc", mean_h0=87, drop_nulls=True))The two-group test is ttest("zinc", group="highbp"), and a paired test is ttest("y1", y_pair="y2").
Regression
samplics:
from samplics import ModelType, SurveyGLM
nh = df.dropna(subset=["zinc"])
glm = SurveyGLM(model=ModelType.LOGISTIC)
glm.estimate(
y=nh["highbp"],
x=nh[["zinc"]],
x_labels=["zinc"],
x_cat=nh[["race"]],
x_cat_labels=["race"],
samp_weight=nh["finalwgt"],
stratum=nh["stratid"],
psu=nh["psuid"],
add_intercept=True,
)svy:
logit = sample.glm.fit(y="highbp", x=["zinc", svy.Cat("race")], family="binomial")
print(logit)family="gaussian" replaces ModelType.LINEAR, and svy.Cat("race", ref=2) replaces x_cat_reference={"race": 2}. to_polars(exponentiate=True) gives odds ratios. svy also fits Poisson, gamma and negative binomial models, and has prediction and marginal effects (GLM tutorial).
Small area estimation
The small area models moved to a separate package, svy-sae, installed with pip install svy-sae. The examples below use svy’s bundled copies of samplics’ milk and county crop data, which hold the same values under other column names.
Area level (Fay-Herriot), samplics:
import pandas as pd
from samplics import EblupAreaModel, FitMethod, load_expenditure_milk
milk = load_expenditure_milk()["data"]
X = pd.get_dummies(milk["major_area"], drop_first=True).astype(float)
fh = EblupAreaModel(method=FitMethod.reml)
fh.fit(yhat=milk["direct_est"], X=X, area=milk["small_area"], error_std=milk["std_error"], tol=1e-10)
fh.predict(X=X, area=milk["small_area"])svy-sae:
import svy
import svy_sae as sae
milk = svy.datasets.load("milk")
fh = sae.AreaLevel(milk).fh(
y="yi",
x=svy.Cat("MajorArea", ref=1),
variance="variance",
area="SmallArea",
method="reml",
)
fh.to_polars("predictions")Unit level (Battese-Harter-Fuller), samplics:
from samplics import EblupUnitModel, FitMethod, load_county_crop, load_county_crop_means
crop = load_county_crop()["data"]
means = load_county_crop_means()["data"]
bhf = EblupUnitModel(method=FitMethod.reml)
bhf.fit(crop["corn_area"], crop[["corn_pixel", "soybeans_pixel"]], crop["county_id"])
bhf.predict(means[["ave_corn_pixel", "ave_soybeans_pixel"]], means["county_id"])svy-sae:
bhf = sae.UnitLevel(svy.datasets.load("cornsoybean")).eblup(
y="CornHec",
x=["CornPix", "SoyBeansPix"],
area="County",
pop_data=svy.datasets.load("cornsoybeanmeans"),
method="reml",
)EbUnitModel (empirical best prediction of poverty indicators) becomes UnitLevel(...).ebp(transformation="log", indicators=["headcount"], threshold=z). EllUnitModel has no equivalent: use ebp(), which is the method that replaced ELL in practice. samplics’ ELL did not run (REML and ML raised an error; the method of moments returned missing estimates).
Default differences that change numbers
Each item says what differs and how to get samplics’ number in svy. Some differences come from samplics bugs that svy fixes; the comparison lists them.
Estimation
- Missing values. samplics’
remove_nan=Truedeletes the rows. svy’sdrop_nulls=Truekeeps them at zero weight, out of the domain, as R’s survey package does. With PSUs in the design the results are identical unless every row of a PSU is missing. Without PSUs the standard errors differ. To get samplics’ number, drop the rows first:data.drop_nulls("zinc"). - Degrees of freedom for domains. samplics uses the full design’s degrees of freedom (PSUs minus strata) for every domain. svy uses the PSUs that contain the domain, minus the strata. Estimates and standard errors are the same; only the confidence intervals differ. To get samplics’ interval, use
est ± t(full df) × se. - Median. samplics interpolates linearly by default; svy’s default
q_methodis"higher". Passq_method="linear". The standard errors do not agree: samplics’ median standard error is wrong (see where samplics was wrong). - Proportions of exactly 0 or 1. samplics returns the interval \((p, p)\). svy returns no interval with the default
ci_method="logit";ci_method="korn-graubard"gives one.
Replicate weights
- BRR degrees of freedom. samplics: half the number of replicates. svy:
n_reps - 1. Setdf=onsvy.BrrWgts. - Bootstrap degrees of freedom. samplics: PSUs minus strata. svy:
n_reps - 1. Set it withsample.design.update_rep_weights(df=...). - Jackknife coefficients on existing columns. Both default to \((R-1)/R\). Pass the documented value with
scale=. - Jackknife with unequal coefficients. With
conservative=False, samplics centers the jackknife pseudo-values; svy centers the replicate estimates, as R does. The difference is small (7e-5 relative in our check) and no svy setting reproduces it. Withvariance_center="estimate"the two agree exactly. - Created BRR weights. Which PSU in each stratum gets the larger multiplier differs. Totals are unaffected. For means and ratios the standard errors differ slightly with few replicates; both are valid BRR estimates.
Weighting
adjust()keeps respondents only. Passrespondents_only=Falseto keep samplics’ full-length weights, with zero for nonrespondents.- Poststratification factors. samplics uses
factoras given. svy’sshares=rescales the shares to sum to 1. When your factors do not sum to 1, pass the totals instead:controls={k: total_weight * f for k, f in factor.items()}. - Raking stopping rule. samplics stops on the change between sweeps and the distance to the controls; svy stops on the distance to the controls. Results differ by at most about the tolerance. Set
tol=1e-12in both to match exactly. - Two categorical variables in
calib_covariates. samplics crosses them into one variable, so the calibration is a poststratification on the cross. In svy, writepoststratify(controls, cells=["a", "b"]), orsvy.Cross(svy.Cat("a"), svy.Cat("b"))incalibrate(). Two separatesvy.Catterms calibrate to the two margins, which is a different adjustment.
Selection
- With-replacement probabilities. samplics reports the expected number of hits, \(np\) (\(n/N\) for SRS). svy reports the probability of being selected at least once, \(1-(1-p)^n\), and weights by its inverse. To use samplics’ Hansen-Hurwitz weights, compute \(1/(np)\) yourself.
- Certainty units. samplics raises
CertaintyErrorwhen a unit’s \(np\) exceeds 1. svy selects it with probability 1 and spreads the rest of the stratum’s sample over the other units.certainty_threshold=sets the cut-off.
Sample size
- Rounding. samplics rounds up once, at the end. svy rounds up after each adjustment (design effect, response rate, finite population correction), so its sizes can be 1 or 2 higher. With no population size,
moe=moe * (resp_rate / deff) ** 0.5, leavingdeffandresp_rateat their defaults of 1, reproduces samplics exactly. - Finite population correction with a design effect. samplics applies the design effect after the correction; svy corrects the inflated size. The difference can be large for small populations, and samplics’ result can exceed the population. No svy setting reproduces samplics; svy’s order is the correct one.
- Allocation ratio. samplics’
kappais \(n_1/n_2\); svy’salloc_ratiois \(n_2/n_1\). Swap the two groups, passalloc_ratio=kappaand read the result in reverse order. - Allocation rounding. samplics rounds up in each stratum, so the total can exceed \(n\). svy rounds to the largest remainder and hits \(n\) exactly.
Tests and models
- Chi-square p-value. samplics reports the uncorrected Pearson p-value; svy reports the Rao-Scott corrected one. The uncorrected value is
scipy.stats.chi2.sf(crosstab.stats.chisq.value, crosstab.stats.chisq.df). - t-test degrees of freedom. samplics uses \(n-1\) (Welch for two groups). svy uses the design degrees of freedom minus 1, as R’s
svyttestdoes. Means, standard errors and the one-sample and paired t statistics agree. - Two-group t statistic. samplics ignores the covariance between the two group means, so its t is not design-based. svy’s matches R’s
svyttest. They agree only without clustering. - GLM confidence intervals and p-values. samplics uses normal quantiles for intervals and \(t(n-p)\) for p-values. svy uses \(t\) with the design degrees of freedom minus the number of predictors, as R’s
svyglmdoes. Coefficients and standard errors agree. To get samplics’ interval, useestimate ± 1.96 × std_err. - Fay-Herriot tolerance. samplics’ default
tol=1e-4stops about 3e-6 from the optimum. Passtol=1e-10to compare at 1e-8. - Box-Cox form. samplics fits \(y^\lambda/\lambda\); svy-sae fits \((y^\lambda-1)/\lambda\). Slopes and variance components agree; the intercepts differ by \(1/\lambda\).
- GLM tolerance. svy’s default
tol=1e-8can stop one iteration earlier on logistic fits (5e-8 relative).tol=1e-10gives the same coefficients to 1e-14.
What has no direct equivalent
- Power calculations (
calculate_power,calculate_power_prop,power_for_one_mean,power_for_one_proportion) and one-sample test sizing (SampleSizeMeanOneSample,SampleSizePropOneSample). svy’scompare_means()andcompare_props()take the power as an input, but svy has no public function that returns the power of a given design. SampleSize.deff(cluster_size, icc). Passdeff=1 + (m - 1) * icc.PopParam.totalinSampleSize. samplics sized totals as means; useestimate_mean().allocate("variable_rate")and the returned sampling rates. Pass the sizes per stratum to the selection method and compute \(n_h/N_h\) yourself.SelectMethod.pps_hvandSelectMethod.grs. Usepps_sys(),pps_brewer()orpps_rs().SampleSelection.inclusion_probs()(probabilities for the whole frame without drawing). Compute \(n_h \times \text{mos}_i / \sum_h \text{mos}\) with Polars.calibrate(additive=True). Usecalibrate(by=)for separate calibration within each domain.- Likelihood ratio chi-square in
CrossTabulation. svy reports Pearson-based statistics only. - A different singleton rule per stratum (
single_psu={stratum: ...}). svy takes one rule per design;svy.Singleton("collapse", using={...})maps each singleton stratum to its partner. trim(),ReplicateWeight().normalize()andbounded=Truewere placeholders in samplics. svy has working trimming, and every weight adjustment also adjusts the replicate weights.
Errors and utilities
| samplics | svy |
|---|---|
SamplicsError |
svy.SvyError |
MethodError |
svy.MethodError |
DimensionError |
svy.DimensionError |
SinglePSUError |
svy.SvyError with a singleton message; declare a rule with singleton= |
CertaintyError |
none: svy selects certainty units |
ProbError |
svy.SvyError |
array_to_dict(x) |
data["x"].value_counts() in Polars |
transform() (Box-Cox) |
UnitLevel in svy-sae takes the transformation as a model option |
Every svy error has a short title, a detail and a code, and prints with the offending parameter and a hint.
Next steps
New to the Sample object?
Take the five-minute Quick Tour →