phplyr

Examples

Gallery

These analyses use the small datasets from phit’s documentation. Each example ran when the documentation was built, and the output shown is the output it produced. The CSV files are in docs/data. The school, mixed-model, and pass-rate examples fit models, so they need the phit extension.

School value added

The schools data has 250 students in 12 schools, with study hours, a prior score, and a final score. A regression on all students gives each student an expected score. Averaging the residuals by school ranks schools by how far their students exceed that expectation.

// Which schools do better than their students' prior scores predict?
// Fit one regression on all students, then average each school's residuals.
$schools = DataFrame::fromCsv('schools.csv');
$fit = $schools |> lm('score ~ prior_score + study_hours');
[$b0, $b_prior, $b_hours] = $fit['coefficients'];

echo $schools |>
    mutate(expected: fn ($r) => $b0 + $b_prior * $r->prior_score + $b_hours * $r->study_hours) |>
    mutate(residual: fn ($r) => $r->score - $r->expected) |>
    group_by('school') |>
    summarize(
        students: n(),
        avg_score: mean('score'),
        avg_residual: mean('residual'),
        share_above: fn ($g) => round(\count(array_filter($g->column('residual'), fn ($e) => $e > 0)) / $g->nrow(), 2),
    ) |>
    mutate(rank: min_rank(desc('avg_residual')), avg_score: fn ($r) => round($r->avg_score, 1), avg_residual: fn ($r) => round($r->avg_residual, 2)) |>
    arrange('rank');
// Which schools do better than their students' prior scores predict?
// Fit one regression on all students, then average each school's residuals.
$schools = DataFrame::fromCsv('schools.csv');
$fit = phplyr($schools)->lm('score ~ prior_score + study_hours');
[$b0, $b_prior, $b_hours] = $fit['coefficients'];

echo phplyr($schools)
    ->mutate(expected: fn ($r) => $b0 + $b_prior * $r->prior_score + $b_hours * $r->study_hours)
    ->mutate(residual: fn ($r) => $r->score - $r->expected)
    ->groupBy('school')
    ->summarize(
        students: n(),
        avg_score: mean('score'),
        avg_residual: mean('residual'),
        share_above: fn ($g) => round(\count(array_filter($g->column('residual'), fn ($e) => $e > 0)) / $g->nrow(), 2),
    )
    ->mutate(rank: min_rank(desc('avg_residual')), avg_score: fn ($r) => round($r->avg_score, 1), avg_residual: fn ($r) => round($r->avg_residual, 2))
    ->arrange('rank');
# A tibble: 12 × 6
   school    students avg_score avg_residual share_above  rank
   <chr>        <int>     <dbl>        <dbl>       <dbl> <int>
 1 school-07       19      83.4        11.1         1        1
 2 school-06       27      76.9         2.28        0.52     2
 3 school-09       16      74.9         2.25        0.69     3
 4 school-02       24      72.9         1.7         0.67     4
 5 school-10       25      71.5         0.66        0.56     5
 6 school-05       20      73           0.23        0.55     6
 7 school-11       23      71.2         0.09        0.39     7
 8 school-01       18      72.9         0.06        0.44     8
 9 school-03       15      69          -1.85        0.4      9
10 school-12       20      64          -5.14        0.05    10
11 school-08       21      64.1        -5.36        0.14    11
12 school-04       22      69.6        -5.95        0.14    12

Schools as random intercepts

The same data as a mixed model: lmer() fits a random intercept for each school. The intraclass correlation is the share of residual variance between schools, and the estimated intercept shifts are shrunk toward zero compared with the raw school means.

// A random-intercept model: students nested in schools. The fixed effects
// are shared; each school gets its own intercept shift.
$schools = DataFrame::fromCsv('schools.csv');
$fit = $schools |> lmer('score ~ study_hours + prior_score + (1 | school)');

foreach ($fit['columns'] as $i => $term) {
    printf("%-12s %8.4f  (se %.4f)\n", $term, $fit['coefficients'][$i], $fit['std_errors'][$i]);
}
$schoolSd = $fit['theta'][0] * $fit['sigma'];
printf("school sd %.3f, residual sd %.3f, ICC %.3f\n\n", $schoolSd, $fit['sigma'], $schoolSd ** 2 / ($schoolSd ** 2 + $fit['sigma'] ** 2));

// Compare each school's raw mean with its estimated intercept shift.
$random = $fit['random'][0];
$effects = array_combine($random['group_labels'], array_column($random['effects'], 0));
echo $schools |>
    group_by('school') |>
    summarize(students: n(), raw_mean: mean('score')) |>
    mutate(intercept_shift: fn ($r) => round($effects[$r->school], 2), raw_mean: fn ($r) => round($r->raw_mean, 1)) |>
    arrange(desc('intercept_shift')) |>
    head(5);
// A random-intercept model: students nested in schools. The fixed effects
// are shared; each school gets its own intercept shift.
$schools = DataFrame::fromCsv('schools.csv');
$fit = phplyr($schools)->lmer('score ~ study_hours + prior_score + (1 | school)');

foreach ($fit['columns'] as $i => $term) {
    printf("%-12s %8.4f  (se %.4f)\n", $term, $fit['coefficients'][$i], $fit['std_errors'][$i]);
}
$schoolSd = $fit['theta'][0] * $fit['sigma'];
printf("school sd %.3f, residual sd %.3f, ICC %.3f\n\n", $schoolSd, $fit['sigma'], $schoolSd ** 2 / ($schoolSd ** 2 + $fit['sigma'] ** 2));

// Compare each school's raw mean with its estimated intercept shift.
$random = $fit['random'][0];
$effects = array_combine($random['group_labels'], array_column($random['effects'], 0));
echo phplyr($schools)
    ->groupBy('school')
    ->summarize(students: n(), raw_mean: mean('score'))
    ->mutate(intercept_shift: fn ($r) => round($effects[$r->school], 2), raw_mean: fn ($r) => round($r->raw_mean, 1))
    ->arrange(desc('intercept_shift'))
    ->head(5);
(Intercept)   46.6627  (se 2.2438)
study_hours    1.6141  (se 0.1609)
prior_score    0.2453  (se 0.0419)
school sd 4.458, residual sd 5.057, ICC 0.437

# A tibble: 5 × 4
  school    students raw_mean intercept_shift
  <chr>        <int>    <dbl>           <dbl>
1 school-07       19     83.4           10.4 
2 school-06       27     76.9            2.27
3 school-09       16     74.9            2.12
4 school-02       24     72.9            1.62
5 school-10       25     71.5            0.57

Rent per square meter

The housing data has eight flats. The first table summarizes area, rent, and rent per square meter by number of bedrooms with across(). The second compares each flat with its bedroom group using by.

// Price per square meter by number of bedrooms, with each flat's
// position relative to its bedroom group.
$housing = DataFrame::fromCsv('housing.csv') |>
    mutate(per_m2: fn ($r) => round($r->monthly_rent / $r->area_m2, 2));

echo $housing |>
    group_by('bedrooms') |>
    summarize(across(['area_m2', 'monthly_rent', 'per_m2'], ['mean' => mean(...), 'max' => max(...)]), flats: n()) |>
    mutate(across(where('double'), fn ($v) => round($v, 1)));

echo "\n", $housing |>
    mutate(vs_group: fn ($r, $g) => round($r->per_m2 - mean('per_m2')($g), 2), _by: 'bedrooms') |>
    arrange('bedrooms', desc('vs_group'));
// Price per square meter by number of bedrooms, with each flat's
// position relative to its bedroom group.
$housing = phplyr(DataFrame::fromCsv('housing.csv'))
    ->mutate(per_m2: fn ($r) => round($r->monthly_rent / $r->area_m2, 2))
    ->get();

echo phplyr($housing)
    ->groupBy('bedrooms')
    ->summarize(across(['area_m2', 'monthly_rent', 'per_m2'], ['mean' => mean(...), 'max' => max(...)]), flats: n())
    ->mutate(across(where('double'), fn ($v) => round($v, 1)));

echo "\n", phplyr($housing)
    ->mutate(vs_group: fn ($r, $g) => round($r->per_m2 - mean('per_m2')($g), 2), _by: 'bedrooms')
    ->arrange('bedrooms', desc('vs_group'));
# A tibble: 3 × 8
  bedrooms area_m2_mean area_m2_max monthly_rent_mean monthly_rent_max
     <int>        <dbl>       <dbl>             <dbl>            <dbl>
1        1         43.9        49.1             1246.            1509.
2        2         66.1        74.4             1902.            2022.
3        3         93.2        93.9             2313.            2386.
# ℹ 3 more variables: per_m2_mean <dbl>, per_m2_max <dbl>, flats <int>
# A tibble: 8 × 5
  area_m2 bedrooms monthly_rent per_m2 vs_group
    <dbl>    <int>        <dbl>  <dbl>    <dbl>
1    45.7        1        1509.   33.0     4.76
2    37          1         960.   25.9    -2.33
3    49.1        1        1268.   25.8    -2.44
4    57.4        2        1805.   31.4     2.39
5    66.4        2        2022.   30.5     1.4 
6    74.4        2        1879.   25.3    -3.8 
7    93.9        3        2387.   25.4     0.59
8    92.4        3        2240.   24.2    -0.59

Pass rates by study time

The learning data records study hours, a practice score, and whether 80 students passed. ntile() splits students into study-hour quartiles, and a logistic regression estimates the effect of an extra hour.

// Pass rates by study-hour quartile, then a logistic regression.
$learning = DataFrame::fromCsv('learning.csv');

echo $learning |>
    mutate(hours_quartile: ntile('study_hours', 4)) |>
    group_by('hours_quartile') |>
    summarize(
        students: n(),
        min_hours: min('study_hours'),
        max_hours: max('study_hours'),
        pass_rate: mean('passed'),
    );

$fit = $learning |> glm('passed ~ study_hours + practice_score', family: 'binomial');
printf("\nOdds ratio per extra study hour: %.3f\n", exp($fit['coefficients'][1]));
// Pass rates by study-hour quartile, then a logistic regression.
$learning = DataFrame::fromCsv('learning.csv');

echo phplyr($learning)
    ->mutate(hours_quartile: ntile('study_hours', 4))
    ->groupBy('hours_quartile')
    ->summarize(
        students: n(),
        min_hours: min('study_hours'),
        max_hours: max('study_hours'),
        pass_rate: mean('passed'),
    );

$fit = phplyr($learning)->glm('passed ~ study_hours + practice_score', family: 'binomial');
printf("\nOdds ratio per extra study hour: %.3f\n", exp($fit['coefficients'][1]));
# A tibble: 4 × 5
  hours_quartile students min_hours max_hours pass_rate
           <int>    <int>     <dbl>     <dbl>     <dbl>
1              1       20      1.24      3.38      0.25
2              2       20      3.44      5.98      0.3 
3              3       20      6         8.83      0.45
4              4       20      8.87     11.8       0.45
Odds ratio per extra study hour: 1.066

Clinic calls by staffing

The clinic data has visitors, staff, and calls for 120 days. The example bands staffing and visitor volume, averages calls, and widens the result into a cross-tabulation.

// Calls by staffing level and visitor volume, as a cross-tabulation.
echo DataFrame::fromCsv('clinic.csv') |>
    mutate(
        staffing: case_when([fn ($r) => $r->staff < 4, 'low'], [fn ($r) => $r->staff < 6, 'mid'], default: 'high'),
        volume: if_else(fn ($r) => $r->visitors >= 250, 'busy', 'quiet'),
    ) |>
    group_by('staffing', 'volume') |>
    summarize(mean_calls: mean('calls'), _groups: 'drop') |>
    mutate(mean_calls: fn ($r) => round($r->mean_calls, 2)) |>
    pivot_wider(namesFrom: 'volume', valuesFrom: 'mean_calls', namesSort: true) |>
    arrange(fn ($r) => array_search($r->staffing, ['low', 'mid', 'high']));
// Calls by staffing level and visitor volume, as a cross-tabulation.
echo phplyr(DataFrame::fromCsv('clinic.csv'))
    ->mutate(
        staffing: case_when([fn ($r) => $r->staff < 4, 'low'], [fn ($r) => $r->staff < 6, 'mid'], default: 'high'),
        volume: if_else(fn ($r) => $r->visitors >= 250, 'busy', 'quiet'),
    )
    ->groupBy('staffing', 'volume')
    ->summarize(mean_calls: mean('calls'), _groups: 'drop')
    ->mutate(mean_calls: fn ($r) => round($r->mean_calls, 2))
    ->pivotWider(namesFrom: 'volume', valuesFrom: 'mean_calls', namesSort: true)
    ->arrange(fn ($r) => array_search($r->staffing, ['low', 'mid', 'high']));
# A tibble: 3 × 3
  staffing  busy quiet
  <chr>    <dbl> <dbl>
1 low      NA     1.9 
2 mid       1.79  1.64
3 high      1.8  NA

From phpstats/phplyr@d319dc5.