EduFlow
SQL للمحلّلين
⁦0%⁩

الوحدة 3 · SQL التحليلي

المجاميع التراكمية وLAG والمتوسطات المتحركة

18 دقراءة 4 دتمرين SQL

الدرس في لمحة

حوّل جدولًا شهريًا أو يوميًا إلى اتجاه بالمجاميع التراكمية، وإعادة الضبط مع بداية كل سنة، ومقارنات LAG وLEAD، والمتوسطات المتحركة على إطارات ROWS صريحة، بما في ذلك ملء الأيام المفقودة أولًا.

  1. 1المجاميع التراكمية ومنذ بداية السنة
  2. 2المقارنة بالصف السابق
  3. 3المتوسطات المتحركة والإطار

لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.

تحتاج المراجعة الشهرية لأعمال رانيا إلى ثلاثة أشياء في شريحة واحدة: الإيراد حسب الشهر، وما تراكم من إجمالي السنة حتى الآن، وهل نما كل شهر مقارنةً بسابقه. يمكنك حساب رقم ما منذ بداية السنة في جدول بيانات لاحقًا. لكن دوال النافذة نفسها التي رتّبت المنتجات في الدرس الماضي تستطيع أيضًا أن تراكم وتقارن، فيخرج الجدول كله من SQL مباشرةً، وبطريقة قابلة لإعادة الإنتاج.

المجاميع التراكمية ومنذ بداية السنة

دالة التجميع كدالة نافذة مع ORDER BY داخل OVER تصبح حسابًا تراكميًا: كل صف يرى الصفوف حتى نفسه. أضف PARTITION BY فيبدأ الحساب من جديد لكل مجموعة. هذه طلبات Cartwheel الشهرية مع عدد منذ بداية السنة يبدأ من جديد في كل يناير:

SQL
WITH monthly AS (
  SELECT strftime('%Y-%m', order_date) AS month, COUNT(*) AS orders
  FROM orders
  WHERE status <> 'cancelled'
  GROUP BY month
)
SELECT
  month,
  orders,
  SUM(orders) OVER (
    PARTITION BY substr(month, 1, 4)
    ORDER BY month
  ) AS orders_ytd
FROM monthly
ORDER BY month
LIMIT 14;

مفتاح الـ partition هو السنة، مأخوذة من الأحرف الأربعة الأولى في تسمية الشهر. يغلق ديسمبر 2024 السنة على 552 طلبًا، ويبدأ يناير 2025 من جديد بـ 84.

نافذتان على الصف نفسه تتكاملان جيدًا. قسمة المجموع التراكمي على مجموع الـ partition كله، 100.0 * SUM(orders) OVER (PARTITION BY substr(month, 1, 4) ORDER BY month) / SUM(orders) OVER (PARTITION BY substr(month, 1, 4))، تعطي النسبة التراكمية من السنة التي بلغها كل شهر. في نشاط موسمي مثل Cartwheel، هذا هو الرقم الذي تتابعه المالية فعلًا: بنهاية أكتوبر 2025 كان قد أُنجز أقل بقليل من ثلثي السنة.

المقارنة بالصف السابق

يعيد LAG(x) قيمة x من الصف السابق بترتيب النافذة، ويعيد LEAD(x) قيمتها من الصف التالي. بعدها يصبح التغيّر بين فترتين عملية حسابية بسيطة:

SQL
WITH monthly AS (
  SELECT strftime('%Y-%m', order_date) AS month, COUNT(*) AS orders
  FROM orders
  WHERE status <> 'cancelled'
  GROUP BY month
)
SELECT
  month,
  orders,
  LAG(orders) OVER (ORDER BY month) AS prev_month,
  orders - LAG(orders) OVER (ORDER BY month) AS change,
  ROUND(100.0 * (orders - LAG(orders) OVER (ORDER BY month))
        / NULLIF(LAG(orders) OVER (ORDER BY month), 0), 1) AS change_pct,
  LAG(orders, 12) OVER (ORDER BY month) AS same_month_last_year
FROM monthly
ORDER BY month DESC
LIMIT 4;

قفز نوفمبر 2025 بـ 193 طلبًا، ثم هبط ديسمبر بـ 140: Black Friday، ثم الركود الذي يليه. الأرقام الشهرية المتتالية مشوّشة في نشاط موسمي، ولهذا يستخدم العمود الأخير LAG(orders, 12)، أي القيمة قبل اثني عشر صفًا، لمنظور سنة مقابل سنة: 325 طلبًا في نوفمبر هذا العام مقابل 120 في نوفمبر الماضي.

لا يكون LAG(orders, 12) «الشهر نفسه في السنة الماضية» إلا إذا كان لكل شهر صف. الشهر الذي لا طلبات فيه لن يكون له صف، وستنزاح كل مقارنة بعده شهرًا واحدًا بصمت. وهذا الافتراض على وشك أن يصبح مهمًا.

المتوسطات المتحركة والإطار

المتوسط المتحرك يخفّف التشويش اليومي. تحتاج النافذة إلى إطار (frame) صريح، أي نطاق الصفوف حول الصف الحالي الذي تراه دالة التجميع: ROWS BETWEEN 6 PRECEDING AND CURRENT ROW هو الصف الحالي مع الستة التي قبله.

عشرة صفوف يومية مرتّبة. للصف الحالي، اليوم 9، يغطي الإطار ROWS BETWEEN 6 PRECEDING AND CURRENT ROW الأيام من 3 إلى 9، أي سبعة صفوف. وحين يتقدّم الصف الحالي يومًا واحدًا، يتحرك الإطار معه. d1 d2 d3 d4 d5 d6 d7 d8 d9 d10 الإطار: من 6 PRECEDING إلى CURRENT ROW (7 صفوف) الصف الحالي
إطار ROWS ينزلق على الصفوف المرتّبة

يعدّ الإطار الصفوف لا الأيام. إذا لم يكن في يوم ما طلبات، فلا صف له، ويمتد الإطار بصمت إلى زمن أبعد. يُظهر الشهر الأول في Cartwheel مدى سوء هذا التضليل: في يناير 2024 وصلت 8 طلبات فقط، في 8 أيام مختلفة. المتوسط على 7 صفوف من الصفوف الموجودة يقول «طلب واحد يوميًا» طوال الشهر. والحقيقة نحو ربع ذلك.

الإصلاح هو تقويم: ولّد صفًا لكل يوم بـ CTE تكراري، واربط به الأعداد اليومية بـ LEFT JOIN، وحوّل الأيام المفقودة إلى أصفار:

SQL
WITH RECURSIVE days(day) AS (
  SELECT '2024-01-01'
  UNION ALL
  SELECT date(day, '+1 day') FROM days WHERE day < '2024-01-31'
),
daily AS (
  SELECT date(order_date) AS day, COUNT(*) AS orders
  FROM orders
  WHERE status <> 'cancelled'
    AND order_date >= '2024-01-01' AND order_date < '2024-02-01'
  GROUP BY day
),
filled AS (
  SELECT d.day, COALESCE(x.orders, 0) AS orders
  FROM days AS d
  LEFT JOIN daily AS x ON x.day = d.day
),
smoothed AS (
  SELECT day, orders,
         ROUND(AVG(orders) OVER (
           ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
         ), 2) AS orders_7d_avg
  FROM filled
)
SELECT * FROM smoothed
WHERE day >= '2024-01-25'
ORDER BY day;

الآن يعرض 31 يناير 0.43 طلب يوميًا خلال الأسبوع الأخير، وهو الرقم الصادق. لاحظ موضع شرط التاريخ: في الـ SELECT الأخير، بعد حساب المتوسط المتحرك في smoothed. تعمل دوال النافذة بعد WHERE، فالتصفية في الـ query نفسه ستترك الأيام الأولى المعروضة بلا تاريخ سابق يُحسب متوسطه. وحيلة التقويم نفسها تحمي LAG(orders, 12) من الأشهر المفقودة.

أصبحت قادرًا على الترتيب والمراكمة والمقارنة. يضع الدرس التالي هذه الأدوات في خدمة التحليلين اللذين يطلبهما كل فريق منتج: القمع (funnel) ومعدل الاحتفاظ (retention) لكل cohort.

أهم النقاط

  • SUM(x) OVER (ORDER BY period) مجموع تراكمي؛ وإضافة PARTITION BY year تجعله يبدأ من جديد كل سنة، أي المجموع منذ بداية السنة.
  • يقرأ LAG(x) قيمة الصف السابق ويقرأ LEAD(x) قيمة الصف التالي، فيكون التغيّر بين فترتين متتاليتين x - LAG(x).
  • يحتاج المتوسط المتحرك (moving average) إلى إطار صريح، مثل ROWS BETWEEN 6 PRECEDING AND CURRENT ROW، وإلى صف لكل فترة، بما فيها الفترات الفارغة.
  • تعمل دوال النافذة بعد WHERE، لذا صفِّ النطاق المعروض في query خارجي، وإلا لن ترى النافذة إلا الصفوف المصفّاة.

قراءات إضافية

تحقّق من فهمك

4 أسئلة

السؤال 1 من 4

يُضاف SUM(revenue) OVER () إلى جدول إيرادات شهري. ماذا يعرض كل صف؟

السؤال 2 من 4

الطلبات الشهرية 84 و63 و70 من يناير إلى مارس. ماذا يعيد orders - LAG(orders) OVER (ORDER BY month) ليناير وفبراير ومارس؟

السؤال 3 من 4

الطلبات اليومية مفقودة في الأيام التي لا مبيعات فيها. ما الخطأ في AVG(orders) OVER (ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) على هذا الجدول؟

السؤال 4 من 4

يُفترض أن يعرض هذا الـ query متوسطًا متحركًا لسبعة أيام للأسبوع الأخير من ديسمبر فقط. لماذا تأتي القيم الأولى منخفضة أكثر من اللازم؟

SQL
SELECT day, AVG(orders) OVER (ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)
FROM filled_days
WHERE day >= '2025-12-25';

تدريب

تمرين SQL

اتجاه إيرادات رانيا

ابنِ جدول الشريحة لرانيا: صف واحد لكل شهر في 2025 (الطلبات غير الملغاة) فيه month (‘YYYY-MM’)، وrevenue، وrunning_revenue (الإيراد المتراكم من يناير حتى ذلك الشهر شاملًا إياه)، وmom_change (إيراد هذا الشهر ناقص إيراد الشهر السابق؛ وNULL ليناير). قرّب أعمدة المال إلى منزلتين عشريتين ورتّب حسب month. الترتيب مهم.

الـ CTE المسمّى monthly صحيح. أما عمودا النافذة في الـ SELECT الأخير فكلاهما خاطئ.

الجداول
customers
(customer_id, first_name, last_name, email, country, city, signup_date, segment, referred_by)
orders
(order_id, customer_id, order_date, status, channel, coupon_code, shipping_fee)
order_items
(order_item_id, order_id, product_id, quantity, unit_price, discount)
products
(product_id, name, category_id, unit_price, unit_cost, launched_on, is_active)
categories
(category_id, name, parent_id)
returns
(return_id, order_item_id, return_date, reason, refund_amount)
employees
(employee_id, first_name, last_name, title, department, manager_id, hire_date, salary)
support_tickets
(ticket_id, customer_id, order_id, opened_at, closed_at, priority, channel, satisfaction, agent_id)
web_sessions
(session_id, customer_id, started_at, source, device, pages_viewed, added_to_cart, purchased)
-- 2025 monthly revenue: running total and change vs previous month
WITH monthly AS (
  SELECT
    strftime('%Y-%m', o.order_date) AS month,
    SUM(oi.quantity * oi.unit_price * (1 - oi.discount)) AS revenue
  FROM orders AS o
  JOIN order_items AS oi ON oi.order_id = o.order_id
  WHERE o.status <> 'cancelled'
    AND o.order_date >= '2025-01-01'
    AND o.order_date < '2026-01-01'
  GROUP BY month
)
SELECT
  month,
  ROUND(revenue, 2) AS revenue,
  ROUND(SUM(revenue) OVER (), 2) AS running_revenue,
  ROUND(revenue - LEAD(revenue) OVER (ORDER BY month), 2) AS mom_change
FROM monthly
ORDER BY month;جارٍ تحميل المحرّر…

Ctrl/⌘ + Enter للتشغيل · يعمل في بيئة معزولة داخل متصفّحك. لا شيء يغادر جهازك.