joinaggregate: оконные агрегаты

Трансформация joinaggregate в Vega-Lite используется для вычисления агрегированных значений и последующего присоединения результата к каждой строке исходного набора данных. В отличие от aggregate, где данные схлопываются до групп, joinaggregate сохраняет оригинальные записи.

Это особенно важно при построении:

  • процентных соотношений;
  • отклонений от среднего;
  • ранжирования;
  • нормализации;
  • сравнений с групповыми метриками;
  • вычислений для оконной аналитики.

Синтаксически joinaggregate напоминает SQL-конструкцию:

AVG(value) OVER ()

или

SUM(value) OVER (PARTITION BY category)

Базовый синтаксис

{
  joinaggregate: [
    {
      op: "average",
      field: "price",
      as: "avg_price"
    }
  ]
}

После выполнения трансформации каждая строка получает новое поле avg_price.


Отличие joinaggregate от aggregate

aggregate

transform: [
  {
    aggregate: [
      { op: "sum", field: "sales", as: "total_sales" }
    ],
    groupby: ["category"]
  }
]

Результат:

category total_sales
A 100
B 200

Исходные строки исчезают.


joinaggregate

transform: [
  {
    joinaggregate: [
      { op: "sum", field: "sales", as: "total_sales" }
    ],
    groupby: ["category"]
  }
]

Результат:

category sales total_sales
A 30 100
A 70 100
B 50 200
B 150 200

Исходные строки сохраняются.


Структура joinaggregate

Основные поля

Поле Назначение
op Агрегирующая операция
field Поле для агрегации
as Имя результирующего поля
groupby Группировка

Поддерживаемые операции

Числовые агрегаты

Операция Описание
sum Сумма
average Среднее
mean Синоним average
median Медиана
min Минимум
max Максимум
count Количество
variance Дисперсия
stdev Стандартное отклонение

Квантили и статистика

Операция Назначение
q1 Первый квартиль
q3 Третий квартиль
stderr Стандартная ошибка
ci0 Нижняя граница доверительного интервала
ci1 Верхняя граница доверительного интервала

Простое вычисление среднего

Исходные данные

[
  { product: "A", sales: 100 },
  { product: "B", sales: 150 },
  { product: "C", sales: 200 }
]

Спецификация

{
  data: {
    values: [
      { product: "A", sales: 100 },
      { product: "B", sales: 150 },
      { product: "C", sales: 200 }
    ]
  },

  transform: [
    {
      joinaggregate: [
        {
          op: "average",
          field: "sales",
          as: "avg_sales"
        }
      ]
    }
  ],

  mark: "bar",

  encoding: {
    x: { field: "product" },
    y: { field: "sales", type: "quantitative" },
    color: {
      condition: {
        test: "datum.sales > datum.avg_sales",
        value: "tomato"
      },
      value: "steelblue"
    }
  }
}

Что происходит

Трансформация вычисляет:

(100 + 150 + 200) / 3 = 150

После этого каждая запись получает:

{
  avg_sales: 150
}

Это позволяет сравнивать строку с глобальным средним.


Групповые агрегаты

Среднее внутри категории

{
  transform: [
    {
      joinaggregate: [
        {
          op: "average",
          field: "sales",
          as: "category_avg"
        }
      ],
      groupby: ["category"]
    }
  ]
}

Пример данных

[
  { category: "A", sales: 10 },
  { category: "A", sales: 20 },
  { category: "B", sales: 50 },
  { category: "B", sales: 70 }
]

Результат

category sales category_avg
A 10 15
A 20 15
B 50 60
B 70 60

Вычисление процентов от общей суммы

Одна из самых распространённых задач.


Спецификация

{
  transform: [
    {
      joinaggregate: [
        {
          op: "sum",
          field: "sales",
          as: "total_sales"
        }
      ]
    },

    {
      calculate: "datum.sales / datum.total_sales",
      as: "percent"
    }
  ]
}

Пошаговое выполнение

Шаг 1: вычисляется общая сумма

100 + 150 + 250 = 500

Шаг 2: значение добавляется ко всем строкам

{
  total_sales: 500
}

Шаг 3: вычисляется доля

sales / total_sales

Результат

sales total_sales percent
100 500 0.2
150 500 0.3
250 500 0.5

Нормализация данных

Отклонение от среднего

{
  transform: [
    {
      joinaggregate: [
        {
          op: "average",
          field: "value",
          as: "avg"
        }
      ]
    },

    {
      calculate: "datum.value - datum.avg",
      as: "deviation"
    }
  ]
}

Z-score

Стандартизация значений.

{
  transform: [
    {
      joinaggregate: [
        {
          op: "average",
          field: "value",
          as: "mean"
        },

        {
          op: "stdev",
          field: "value",
          as: "std"
        }
      ]
    },

    {
      calculate: "(datum.value - datum.mean) / datum.std",
      as: "zscore"
    }
  ]
}

Вычисление доли внутри группы

Продажи товара внутри категории

{
  transform: [
    {
      joinaggregate: [
        {
          op: "sum",
          field: "sales",
          as: "category_total"
        }
      ],
      groupby: ["category"]
    },

    {
      calculate: "datum.sales / datum.category_total",
      as: "share"
    }
  ]
}

Результат

category sales category_total share
A 10 40 0.25
A 30 40 0.75

Построение reference line

joinaggregate часто используется для создания линий среднего значения.


Средняя линия

{
  layer: [
    {
      mark: "bar",
      encoding: {
        x: { field: "product" },
        y: { field: "sales", type: "quantitative" }
      }
    },

    {
      transform: [
        {
          joinaggregate: [
            {
              op: "average",
              field: "sales",
              as: "avg_sales"
            }
          ]
        }
      ],

      mark: {
        type: "rule",
        color: "red"
      },

      encoding: {
        y: {
          field: "avg_sales",
          type: "quantitative"
        }
      }
    }
  ]
}

Комбинация с window

Различия

joinaggregate window
Простые агрегаты Порядковые вычисления
Не требует сортировки Обычно требует сортировку
Нет frame Есть frame
Нет row_number Есть ranking

Когда использовать joinaggregate

Подходит для:

  • среднего по группе;
  • общей суммы;
  • медианы;
  • статистических метрик.

Когда использовать window

Подходит для:

  • cumulative sum;
  • moving average;
  • ranking;
  • lag/lead;
  • rolling computations.

Отличие от SQL Window Functions

SQL

SUM(sales) OVER (PARTITION BY category)

Vega-Lite

{
  joinaggregate: [
    {
      op: "sum",
      field: "sales",
      as: "category_total"
    }
  ],
  groupby: ["category"]
}

Несколько агрегатов одновременно

{
  transform: [
    {
      joinaggregate: [
        {
          op: "sum",
          field: "sales",
          as: "total"
        },

        {
          op: "average",
          field: "sales",
          as: "avg"
        },

        {
          op: "max",
          field: "sales",
          as: "max_sales"
        },

        {
          op: "min",
          field: "sales",
          as: "min_sales"
        }
      ]
    }
  ]
}

Использование с calculate

Наиболее типичная схема:

joinaggregate
    ↓
calculate
    ↓
encoding

Пример

{
  transform: [
    {
      joinaggregate: [
        {
          op: "sum",
          field: "profit",
          as: "total_profit"
        }
      ]
    },

    {
      calculate: "datum.profit / datum.total_profit * 100",
      as: "profit_percent"
    }
  ]
}

Использование внутри faceting

При использовании facet трансформации работают внутри каждого фасета.


Пример

{
  facet: {
    field: "region"
  },

  spec: {
    transform: [
      {
        joinaggregate: [
          {
            op: "average",
            field: "sales",
            as: "avg_sales"
          }
        ]
      }
    ]
  }
}

Среднее будет вычисляться отдельно для каждого региона.


Использование с временными рядами

Среднее значение по месяцу

{
  transform: [
    {
      timeUnit: "month",
      field: "date",
      as: "month"
    },

    {
      joinaggregate: [
        {
          op: "average",
          field: "sales",
          as: "month_avg"
        }
      ],
      groupby: ["month"]
    }
  ]
}

Производительность

joinaggregate требует прохода по данным и хранения агрегированных значений.

Для больших наборов данных:

  • лучше минимизировать число агрегатов;
  • избегать лишних groupby;
  • предварительно агрегировать данные на сервере;
  • использовать window только при необходимости.

Внутренний механизм работы

Этапы вычисления

1. Формирование групп

Если указан groupby:

groupby: ["category"]

создаются отдельные группы.


2. Вычисление агрегатов

Для каждой группы вычисляются:

sum
average
median
count
...

3. Присоединение результата

Полученные значения копируются в каждую строку группы.


Типичные ошибки

Ошибка №1: ожидание схлопывания данных

joinaggregate не уменьшает количество строк.


Ошибка №2: отсутствие groupby

{
  joinaggregate: [
    {
      op: "sum",
      field: "sales",
      as: "total"
    }
  ]
}

Без groupby вычисляется глобальная сумма.


Ошибка №3: конфликт имён

as: "sales"

Может перезаписать оригинальное поле.


Ошибка №4: использование строковых полей

field: "name"
op: "sum"

Числовые операции требуют numeric field.


Практический пример: доля продаж региона

{
  data: {
    values: [
      { region: "East", sales: 100 },
      { region: "East", sales: 200 },
      { region: "West", sales: 50 },
      { region: "West", sales: 150 }
    ]
  },

  transform: [
    {
      joinaggregate: [
        {
          op: "sum",
          field: "sales",
          as: "region_total"
        }
      ],

      groupby: ["region"]
    },

    {
      calculate: "datum.sales / datum.region_total",
      as: "share"
    }
  ],

  mark: "bar",

  encoding: {
    x: { field: "region" },

    y: {
      field: "share",
      type: "quantitative"
    },

    tooltip: [
      { field: "sales" },
      { field: "region_total" },
      { field: "share", format: ".2%" }
    ]
  }
}

Практический пример: выделение аномалий

{
  transform: [
    {
      joinaggregate: [
        {
          op: "average",
          field: "value",
          as: "mean"
        },

        {
          op: "stdev",
          field: "value",
          as: "std"
        }
      ]
    },

    {
      calculate: "abs(datum.value - datum.mean) > datum.std * 2",
      as: "isOutlier"
    }
  ]
}

Связь с другими трансформациями

joinaggregate + filter

[
  {
    joinaggregate: [
      {
        op: "average",
        field: "sales",
        as: "avg"
      }
    ]
  },

  {
    filter: "datum.sales > datum.avg"
  }
]

joinaggregate + lookup

[
  {
    lookup: "id",
    from: ...
  },

  {
    joinaggregate: ...
  }
]

joinaggregate + fold

[
  {
    fold: ["A", "B", "C"]
  },

  {
    joinaggregate: ...
  }
]

Архитектурная роль joinaggregate

Трансформация занимает промежуточное положение между:

  • простыми агрегатами (aggregate);
  • полноценными оконными функциями (window).

Она обеспечивает:

  • вычисление глобальных метрик;
  • вычисление групповых статистик;
  • сохранение строк;
  • возможность последующих вычислений;
  • декларативную аналитику внутри Vega-Lite.

Когда joinaggregate является лучшим выбором

Подходит идеально

  • проценты от суммы;
  • сравнение со средним;
  • статистические метрики;
  • baseline calculations;
  • reference values;
  • групповые totals.

Не подходит

  • moving average;
  • running totals;
  • ranking;
  • cumulative calculations;
  • вычисления, зависящие от порядка строк.

Для таких задач используется window.


Полная схема работы

Исходные строки
        ↓
groupby
        ↓
агрегирование
        ↓
присоединение результата
        ↓
calculate/filter/window
        ↓
encoding
        ↓
render