phibble

Guide

Getting started

Loading phibble

Install phibble with Composer, and load Composer’s autoloader once at the top of a script. Frameworks such as Laravel load it already.

composer require phpstats/phibble

The examples on this site import the DataFrame class and phibble’s functions like this:

require __DIR__.'/vendor/autoload.php';

use Phibble\DataFrame;
use function Phibble\{tibble, tribble};
use function Phibble\Expr\{col, desc, mean, n};

Phibble\Expr has functions named min, max, sum, round, abs, log, floor, str_pad, str_split, str_replace, and a few others that share names with PHP built-ins. In a file that imports them, a bare round($x) calls phibble’s expression helper. Write \round($x) for PHP’s function.

Building data frames

tibble() builds a data frame column by column. Each named argument is a column. A list gives one value per row, and a single value is repeated for every row. A closure receives the data frame built so far, so a column can be computed from earlier ones.

$scores = tibble(
    student: ['ana', 'ben', 'cy'],
    score: [81.5, 74.0, null],
    passed: fn (DataFrame $t) => array_map(fn ($s) => $s === null ? null : $s >= 75, $t['score']),
    term: 'fall',
);

echo $scores;
# A tibble: 3 × 4
  student score passed term 
  <chr>   <dbl> <lgl>  <chr>
1 ana      81.5 TRUE   fall 
2 ben      74   FALSE  fall 
3 cy       NA   NA     fall

tribble() takes the column names first and then one list per row, which is easier to read for small literal tables:

$sites = tribble(
    ['site', 'lat', 'opened'],
    ['north', 44.9, 2019],
    ['south', 41.2, 2021],
);

echo $sites;
# A tibble: 2 × 3
  site    lat opened
  <chr> <dbl>  <int>
1 north  44.9   2019
2 south  41.2   2021

DataFrame::fromRows() takes a list of associative arrays. Keys missing from a row are NA. DataFrame::fromColumns() takes an array of columns and accepts explicit types.

$rows = DataFrame::fromRows([
    ['id' => 1, 'dose' => 2.5, 'arm' => 'a'],
    ['id' => 2, 'arm' => 'b'],
]);
$cols = DataFrame::fromColumns(['id' => [1, 2], 'arm' => ['a', 'b']], types: ['arm' => 'factor']);

echo $rows, "\n\n", $cols;
# A tibble: 2 × 3
     id  dose arm  
  <int> <dbl> <chr>
1     1   2.5 a    
2     2  NA   b    

# A tibble: 2 × 2
     id arm  
  <int> <fct>
1     1 a    
2     2 b

DataFrame::fromCsv() reads a CSV file and guesses each column’s type. The input and output guide covers its options.

Printing

echo $df prints the data frame the way tibble does. The header gives the size, each column shows its type, and data frames with more than 20 rows show the first 10. Numbers show three significant digits and NA prints as NA (or <NA> in text columns).

$penguins = DataFrame::fromCsv('penguins.csv');

echo $penguins;
# A tibble: 344 × 8
   species island    bill_len bill_dep flipper_len body_mass sex     year
   <chr>   <chr>        <dbl>    <dbl>       <int>     <int> <chr>  <int>
 1 Adelie  Torgersen     39.1     18.7         181      3750 male    2007
 2 Adelie  Torgersen     39.5     17.4         186      3800 female  2007
 3 Adelie  Torgersen     40.3     18           195      3250 female  2007
 4 Adelie  Torgersen     NA       NA            NA        NA <NA>    2007
 5 Adelie  Torgersen     36.7     19.3         193      3450 female  2007
 6 Adelie  Torgersen     39.3     20.6         190      3650 male    2007
 7 Adelie  Torgersen     38.9     17.8         181      3625 female  2007
 8 Adelie  Torgersen     39.2     19.6         195      4675 male    2007
 9 Adelie  Torgersen     34.1     18.1         193      3475 <NA>    2007
10 Adelie  Torgersen     42       20.2         190      4250 <NA>    2007
# ℹ 334 more rows

glimpse() returns one line per column, which suits wide data frames:

$penguins = DataFrame::fromCsv('penguins.csv');

echo $penguins->glimpse();
Rows: 344
Columns: 8
$ species     <chr> "Adelie", "Adelie", "Adelie", "Adelie", "Adelie", "Adelie"…
$ island      <chr> "Torgersen", "Torgersen", "Torgersen", "Torgersen", "Torge…
$ bill_len    <dbl> 39.1, 39.5, 40.3, NA, 36.7, 39.3, 38.9, 39.2, 34.1, 42.0, …
$ bill_dep    <dbl> 18.7, 17.4, 18.0, NA, 19.3, 20.6, 17.8, 19.6, 18.1, 20.2, …
$ flipper_len <int> 181, 186, 195, NA, 193, 190, 181, 195, 193, 190, 186, 180,…
$ body_mass   <int> 3750, 3800, 3250, NA, 3450, 3650, 3625, 4675, 3475, 4250, …
$ sex         <chr> "male", "female", "female", NA, "female", "male", "female"…
$ year        <int> 2007, 2007, 2007, 2007, 2007, 2007, 2007, 2007, 2007, 2007…

format(n: 30, width: 120) prints more rows or a wider table.

Types and NA

Each column has one type from the Phibble\Type enum: Float, Int, Bool, String, Factor, Date, DateTime, or List. Types are inferred from the values, and PHP null is NA in every type. The types guide describes inference, casts, and factors.

Reading values

A column reads as a PHP list with $df->name, $df['name'], or $df->column('name'). Rows are Phibble\Row objects, which read values as properties or array keys. Iterating a data frame yields its rows. An unknown column name throws Phibble\Exception\ColumnNotFound; names are never matched partially.

$df = tibble(x: [1, 2, 3], y: ['a', 'b', 'c']);

var_dump($df->x === $df['x']);
echo json_encode($df->column('y')), "\n";

$row = $df->row(1);
echo $row->x, ' ', $row['y'], "\n";

foreach ($df as $i => $row) {
    echo "{$i}: {$row->y}\n";
}

try {
    $df->z;
} catch (Phibble\Exception\ColumnNotFound $e) {
    echo $e->getMessage(), "\n";
}
bool(true)
["a","b","c"]
2 b
0: a
1: b
2: c
Column 'z' not found. Available columns: x, y.

Verbs

DataFrames have the dplyr verbs as methods: select(), filter(), mutate(), rename(), arrange(), groupBy(), summarize(), distinct(), slice(), head(), and join(). Each returns a new data frame.

filter() and mutate() accept a row closure, which receives a Row and returns a value. arrange() takes column names, or [name, 'desc'] pairs.

$penguins = DataFrame::fromCsv('penguins.csv');

$heavy = $penguins
    ->select(['species', 'island', 'body_mass', 'year'])
    ->filter(fn ($r) => $r->body_mass !== null && $r->body_mass > 5500)
    ->mutate(['kg' => fn ($r) => $r->body_mass / 1000])
    ->arrange([['body_mass', 'desc']])
    ->head(5);

echo $heavy;
# A tibble: 5 × 5
  species island body_mass  year    kg
  <chr>   <chr>      <int> <int> <dbl>
1 Gentoo  Biscoe      6300  2007  6.3 
2 Gentoo  Biscoe      6050  2007  6.05
3 Gentoo  Biscoe      6000  2008  6   
4 Gentoo  Biscoe      6000  2009  6   
5 Gentoo  Biscoe      5950  2008  5.95

The same verbs accept expressions built with col() and the helper functions. Expressions are evaluated a whole column at a time, and they are the form that SQL and Polars backends can translate.

$penguins = DataFrame::fromCsv('penguins.csv');

$heavy = $penguins
    ->select(['species', 'island', 'body_mass', 'year'])
    ->filter(col('body_mass')->gt(5500))
    ->mutate(['kg' => col('body_mass')->div(1000)])
    ->arrange([desc(col('body_mass'))])
    ->head(5);

echo $heavy;
# A tibble: 5 × 5
  species island body_mass  year    kg
  <chr>   <chr>      <int> <int> <dbl>
1 Gentoo  Biscoe      6300  2007  6.3 
2 Gentoo  Biscoe      6050  2007  6.05
3 Gentoo  Biscoe      6000  2008  6   
4 Gentoo  Biscoe      6000  2009  6   
5 Gentoo  Biscoe      5950  2008  5.95

Groups

groupBy() records grouping columns on the data frame. summarize(), or its alias summarise(), returns one row per group, with groups in sorted order and NA last, as dplyr does. With several grouping columns, the result keeps all but the last.

$penguins = DataFrame::fromCsv('penguins.csv');

$summary = $penguins
    ->filter(col('body_mass')->isNa()->not())
    ->groupBy(['species', 'sex'])
    ->summarize([
        'n' => n(),
        'mass' => mean(col('body_mass')),
        'flipper' => median(col('flipper_len')),
    ]);

echo $summary;
# A tibble: 8 × 5
# Groups:   species [3]
  species   sex        n  mass flipper
  <chr>     <chr>  <int> <dbl>   <dbl>
1 Adelie    female    73 3369.    188 
2 Adelie    male      73 4043.    193 
3 Adelie    <NA>       5 3540     186 
4 Chinstrap female    34 3527.    192 
5 Chinstrap male      34 3939.    200.
6 Gentoo    female    58 4680.    212 
7 Gentoo    male      61 5485.    221 
8 Gentoo    <NA>       4 4588.    216

In a grouped data frame, filter() and mutate() work within each group. An aggregate inside mutate() is computed per group and repeated for each of the group’s rows:

$penguins = DataFrame::fromCsv('penguins.csv')->dropNa(['body_mass']);

$centered = $penguins
    ->groupBy(['species'])
    ->mutate(['vs_species' => col('body_mass')->sub(mean(col('body_mass')))])
    ->select(['species', 'body_mass', 'vs_species'])
    ->head(4);

echo $centered;
# A tibble: 4 × 3
# Groups:   species [1]
  species body_mass vs_species
  <chr>       <int>      <dbl>
1 Adelie       3750       49.3
2 Adelie       3800       99.3
3 Adelie       3250     -451. 
4 Adelie       3450     -251.

A row closure receives the row’s group as its second argument: fn (Row $r, DataFrame $group) => ....

By default, groups are the combinations that occur in the data. With groupBy($names, drop: false), as with dplyr’s .drop = FALSE, a factor key also yields a group for each level that has no rows. Such a group has zero rows, so n() is 0 and sum() is 0.

$penguins = DataFrame::fromCsv('penguins.csv')
    ->asFactor('species')
    ->filter(col('species')->ne('Chinstrap'));

echo $penguins->groupBy(['species'], drop: false)->summarize(['n' => n()]);
# A tibble: 3 × 2
  species       n
  <fct>     <int>
1 Adelie      152
2 Chinstrap     0
3 Gentoo      124

Distinct rows and joins

distinct() keeps the first row of each combination of the given columns. join() takes the other table, the key columns, and the join type: inner, left, right, full, semi, anti, or cross. Joins follow dplyr 1.1, including its column naming and the rule that NA keys match each other.

$penguins = DataFrame::fromCsv('penguins.csv');
$islands = tribble(
    ['island', 'area_km2'],
    ['Biscoe', 6.1],
    ['Dream', 0.9],
);

$joined = $penguins
    ->distinct(['species', 'island'])
    ->join($islands, ['island'], 'left');

echo $joined;
# A tibble: 5 × 3
  species   island    area_km2
  <chr>     <chr>        <dbl>
1 Adelie    Torgersen     NA  
2 Adelie    Biscoe         6.1
3 Adelie    Dream          0.9
4 Gentoo    Biscoe         6.1
5 Chinstrap Dream          0.9

Besides equality keys, a join condition can compare columns, as dplyr’s join_by() does. [$left, $op, $right] with >=, >, <=, or < keeps the pairs that satisfy the comparison, and all conditions must hold. One ['closest', $left, $op, $right] condition keeps, for each row of x, only the y rows with the nearest value: the largest for >= and >, the smallest for <= and <. dplyr’s between(), within(), and overlaps() are pairs of comparisons. In a rolling join, the columns used in comparisons are kept from both tables:

// Each order gets the latest price set on or before its date.
$orders = tribble(
    ['order', 'item', 'day'],
    [1, 'tea', 3],
    [2, 'tea', 9],
    [3, 'coffee', 4],
);
$prices = tribble(
    ['item', 'from_day', 'price'],
    ['tea', 1, 2.5],
    ['tea', 5, 2.75],
    ['coffee', 1, 4.0],
    ['coffee', 6, 4.5],
);

echo $orders->join($prices, ['item', ['closest', 'day', '>=', 'from_day']], 'left');
# A tibble: 3 × 5
  order item     day from_day price
  <int> <chr>  <int>    <int> <dbl>
1     1 tea        3        1  2.5 
2     2 tea        9        5  2.75
3     3 coffee     4        1  4

Immutability

Methods never change the data frame they are called on:

$df = tibble(x: [1, 2, 3]);
$doubled = $df->mutate(['x' => col('x')->mul(2)]);

echo json_encode($df->x), ' ', json_encode($doubled->x), "\n";
[1,2,3] [2,4,6]

Missing values

PHP null is a missing value. Arithmetic with it gives null, and an aggregate over a column that holds it gives null unless na_rm: true is passed:

$orders = tibble(total: [20.0, null, 35.0]);

echo $orders->mutate(['with_tax' => col('total')->mul(1.1)]), "\n";

echo $orders->summarize([
    'mean' => mean(col('total')),
    'mean_known' => mean(col('total'), na_rm: true),
]);
# A tibble: 3 × 2
  total with_tax
  <dbl>    <dbl>
1    20     22  
2    NA     NA  
3    35     38.5
# A tibble: 1 × 2
   mean mean_known
  <dbl>      <dbl>
1    NA       27.5

Method chains and the pipe operator

Every verb returns a new table, so verbs chain as methods on PHP 8.3, 8.4, and 8.5. PHP 8.5’s |> passes a value to a callable, and arrow functions let the same pipeline use it. phplyr provides verbs that return such callables, so a phplyr pipeline needs no arrow functions.

$penguins = DataFrame::fromCsv('penguins.csv');

$counts = $penguins |>
    (fn (DataFrame $f) => $f->groupBy(['island'])) |>
    (fn (DataFrame $f) => $f->summarize(['n' => n()])) |>
    (fn (DataFrame $f) => $f->arrange([desc(col('n'))]));

echo $counts;
$penguins = DataFrame::fromCsv('penguins.csv');

$counts = $penguins
    ->groupBy(['island'])
    ->summarize(['n' => n()])
    ->arrange([desc(col('n'))]);

echo $counts;
# A tibble: 3 × 2
  island        n
  <chr>     <int>
1 Biscoe      168
2 Dream       124
3 Torgersen    52

From phpstats/phibble@36d0f3f.