সরাসরি পর্বে যান
পর্ব ৬১ · ৭৭-এর মধ্যে · মডিউল ১২

Data science-এর জন্য SQL — window function, cohort আর funnel

SQL for Data Science — Window Functions, Cohorts and Funnels

Taught in Bangla, this lesson runs live SQLite editors on a seeded shop database: running totals, RANK, NTILE, LAG and moving averages over a filled calendar, deduplication and 30-minute sessions, ordered funnels, monthly cohort retention with NULL for unseen months, percentiles without PERCENTILE_CONT, and fan-out and NULL traps. In-browser Python rebuilds the numbers in pandas and builds model features.

এই পর্বে যা শিখবেন

  • গ্রাহকের ইতিহাসে running total, RANK, NTILE, LAG আর LEAD; খালি দিন ভরে ৭ দিনের moving average
  • ROW_NUMBER দিয়ে duplicate বাদ দেওয়া আর LAG ও ৩০ মিনিটের নিয়মে session বানানো
  • Funnel-এর ধাপভিত্তিক conversion আর মাসিক signup cohort-এর retention টেবিল SQL-এ
  • PERCENTILE_CONT ছাড়া percentile, আর fan-out, NULL, সময়-অঞ্চল ও COUNT(DISTINCT)-এর ফাঁদ
  • SQL-এর ফল Python-এর sqlite3 দিয়ে pandas-এ এনে মিলিয়ে দেখা, heatmap আঁকা আর model-এর feature বানানো

SQL-এর ব্যাকরণ এই পর্বে আবার শেখানো হবে না। Window function-এর OVER, PARTITION BY আর frame আছে ডাটাবেস ও SQL কোর্সের পর্ব ২৬-এ, CTE ও recursive query একই কোর্সের পর্ব ২৫-এ, আর duplicate, pivot ও median-এর প্রচলিত প্যাটার্ন পর্ব ২৭-এ। এখানে প্রশ্নগুলো data scientist-এর: কারা ফিরে আসেন, কোথায় ক্রেতা ঝরে পড়েন, কোন সংখ্যাটি আসলে ভুল। নয়টি SQL এডিটর পাতাতেই চলে (sql.js, অর্থাৎ ব্রাউজারে SQLite), আর প্রতিবার নতুন করে একই ডেটাবেস বানায়, তাই নিশ্চিন্তে বদলে চালান। শেষের চারটি Python ব্লকও পাতাতেই চলে।

১ · বাজারঘরের পাঁচটি টেবিল — আর সময় কেন UTC-তে

মুদি দোকানের হিসাব খাতা ভাবুন। এক খাতায় ক্রেতার নাম, আরেকটিতে প্রতিদিনের বিক্রি, তৃতীয়টিতে প্রতিটি বিক্রির জিনিসের তালিকা। মালিক জানতে চান, “গত মাসে নতুন যাঁরা এসেছিলেন, তাঁদের কতজন আবার এলেন?” উত্তর কোনো একটি খাতায় লেখা নেই; খাতাগুলো মিলিয়ে, ক্রমে সাজিয়ে, তারপর গুনতে হয়। Data science-এর SQL ঠিক এই কাজ।

বাজারঘরের কাল্পনিক ডেটায় পাঁচটি টেবিল। ২০২৬ সালের জানুয়ারি থেকে এপ্রিল, প্রতি মাসে ৩০ জন করে মোট ১২০ জন গ্রাহক যোগ দিয়েছেন; জানুয়ারি থেকে জুন পর্যন্ত ৩৯০টি অর্ডার, ৬২৯টি অর্ডার-লাইন; আর শুধু জুন মাসের ৩৭৯টি ক্লিক-ঘটনা (event). ডেটা একটি seed (৬১) দেওয়া Python script দিয়ে বানিয়ে পাতায় বসানো, তাই সবাই একই সংখ্যা পাবেন।

বাজারঘরের schema — এক থেকে অনেক সম্পর্ক

customers 1 → n orders 1 → n order_items; customers 1 → n events; every timestamp stored in UTC

Bazarghar schema with one-to-many relationships customers customer_id (PK)signup_datedistrict orders order_id (PK)customer_idorder_ts (UTC)statusdelivery_fee, discount events event_id (PK)customer_idevent_ts (UTC)event_type order_items order_idproduct_id → productsqty, unit_price 1n 1n 1n fan-out ↓ ঢাকার সময় = UTC + ৬ ঘণ্টা
ছবি ৬১.১ — প্রতিটি তীর এক থেকে অনেক। একজন গ্রাহকের অনেক অর্ডার, একটি অর্ডারের অনেক লাইন। orders আর order_items join করলে একটি অর্ডারের সারি তার লাইনের সংখ্যার সমান বার আসে; অংশ ৮-এ দেখবেন কেন এটি বিপজ্জনক।

সব সময় রাখা আছে UTC-তে, যা বড় সিস্টেমের প্রচলিত নিয়ম: সার্ভার যে দেশেই থাকুক, মুহূর্তটি একই থাকে। কিন্তু ব্যবসার প্রশ্ন আসে ঢাকার ঘড়িতে (“১ জুন কত অর্ডার?”), আর বাংলাদেশের time zone Asia/Dhaka এখন UTC-র চেয়ে ৬ ঘণ্টা এগিয়ে। ঢাকার রাত বারোটার পর থেকে ভোর ছয়টা পর্যন্তের অর্ডার UTC-তে আগের তারিখে পড়ে; এই ডেটায় এমন অর্ডার ১৮টি। তাই হিসাবের শুরুতেই রূপান্তর: SQLite-এ datetime(order_ts, '+6 hours'). তারিখের পার্থক্য দিনে মাপতে julianday(): দুটি julianday-এর বিয়োগফল ভগ্নাংশসহ দিন।

২ · গ্রাহকের ইতিহাস — running total, ranking আর LAG

Window function প্রতিটি সারির পাশে একদল সারির হিসাব বসায়, কিন্তু GROUP BY-এর মতো সারি গুটিয়ে ফেলে না। PARTITION BY customer_id বলে “প্রতিটি গ্রাহকের সারি আলাদা দল”, আর ORDER BY বলে দলের ভেতরে ক্রম। প্রথম এডিটরে একটি view (নাম দেওয়া কোয়েরি) প্রতিটি delivered অর্ডারের মূল্য হিসাব করে, তারপর দুটি প্রশ্ন: এক গ্রাহকের ইতিহাস, আর সব গ্রাহকের ক্রম। একই OVER বারবার না লিখতে WINDOW w AS (…) দিয়ে নাম দেওয়া হয়েছে।

CREATE VIEW ov AS
SELECT o.order_id, o.customer_id,
       datetime(o.order_ts, '+6 hours') AS ts_dhaka,
       SUM(i.qty * i.unit_price)        AS value
FROM orders o JOIN order_items i ON i.order_id = o.order_id
WHERE o.status = 'delivered'
GROUP BY o.order_id;

-- এক গ্রাহকের ইতিহাস: কততম অর্ডার, চলমান খরচ, নিজের অর্ডারের মধ্যে ক্রম
SELECT order_id, ts_dhaka, value,
       ROW_NUMBER() OVER w                                       AS nth,
       SUM(value)   OVER w                                       AS running_spend,
       RANK() OVER (PARTITION BY customer_id ORDER BY value DESC) AS value_rank
FROM ov
WHERE customer_id = 87
WINDOW w AS (PARTITION BY customer_id ORDER BY ts_dhaka
             ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
ORDER BY ts_dhaka;

-- সব গ্রাহকের মোট খরচের ক্রম আর quartile
SELECT customer_id, SUM(value) AS total,
       RANK()   OVER (ORDER BY SUM(value) DESC) AS rnk,
       NTILE(4) OVER (ORDER BY SUM(value) DESC) AS quartile
FROM ov GROUP BY customer_id
ORDER BY rnk LIMIT 6;

গ্রাহক ৮৭-এর ১৪টি অর্ডার। ROW_NUMBER ১ থেকে ১৪, আর running_spend ২,৪০০ থেকে বেড়ে শেষে ২৫,৩৯০ টাকা। value_rank-এ RANK-এর টাই দেখুন: তিনটি অর্ডারের মূল্য ঠিক ২,৪০০, তিনটিই ক্রম ৪, আর পরের অর্ডার (১,৯৪০) পায় ৭, কারণ RANK টাইয়ের পর ফাঁক রাখে। ফাঁক না চাইলে DENSE_RANK. দ্বিতীয় ফলে শীর্ষ ছয়জন: গ্রাহক ৪৩ (২৬,৩৭০), ৮৭ (২৫,৩৯০), ৫৩, ৭৩, ৩২ আর ১১৬; সবাই NTILE(4)-এর প্রথম ভাগে। NTILE মান দেখে না, শুধু সারি গোনে: ১০৫ জন ক্রেতাকে ২৭, ২৬, ২৬, ২৬ জনের চার ভাগে ভাগ করে।

এবার LAG: একই partition-এর আগের সারির মান। প্রতিটি অর্ডারের পাশে আগের অর্ডার থেকে কত দিন, আর LEAD দিয়ে পরের অর্ডারের নম্বর। তারপর একটি পুনরায় কেনার (repeat purchase) সারাংশ: কতজন দ্বিতীয়বার কিনেছেন, ৩০ দিনের মধ্যে কতজন, আর প্রথম থেকে দ্বিতীয় অর্ডারে গড়ে কত দিন।

CREATE VIEW g AS
SELECT customer_id, order_id, order_ts,
       ROW_NUMBER() OVER w AS nth,
       ROUND(julianday(order_ts) - julianday(LAG(order_ts) OVER w), 1) AS gap_days,
       LEAD(order_id) OVER w AS next_order
FROM orders
WHERE status = 'delivered'
WINDOW w AS (PARTITION BY customer_id ORDER BY order_ts);

SELECT * FROM g WHERE customer_id = 18;

-- পুনরায় কেনা: প্রথম থেকে দ্বিতীয় অর্ডারের ফাঁক
SELECT COUNT(DISTINCT customer_id)                     AS buyers,
       SUM(nth = 2)                                    AS repeaters,
       SUM(nth = 2 AND gap_days <= 30)                 AS repeat_30d,
       ROUND(AVG(CASE WHEN nth = 2 THEN gap_days END), 1) AS avg_gap_1_2,
       ROUND(AVG(gap_days), 1)                         AS avg_gap_all
FROM g;

গ্রাহক ১৮-এর পাঁচটি অর্ডারের ফাঁক ১২.৩, ২২.০, ১৫.১ আর ১৫.৮ দিন; প্রথম সারির gap_days আর শেষ সারির next_order NULL, কারণ আগে বা পরে কোনো সারি নেই। সারাংশ: 105 | 62 | 56 | 15.8 | 13.9. অর্থাৎ ১০৫ জন ক্রেতার ৬২ জন (৫৯%) দ্বিতীয়বার কিনেছেন, ৫৬ জন ৩০ দিনের মধ্যে। SUM(nth = 2) কাজ করে, কারণ SQLite-এ তুলনার ফল ১ বা ০; PostgreSQL-এ লিখতে হয় COUNT(*) FILTER (WHERE nth = 2) বা SUM(CASE …).

৫৯% সংখ্যাটি একটু অন্যায্য। এপ্রিলে যোগ দেওয়া গ্রাহক দ্বিতীয়বার কেনার জন্য জানুয়ারির গ্রাহকের চেয়ে অনেক কম সময় পেয়েছেন। যাঁর দ্বিতীয় অর্ডার এখনও হয়নি, তাঁর ফাঁক “অন্তত এত দিন”, যাকে বলে right censoring. ন্যায্য তুলনার দুই পথ: সবাইকে সমান সময়ের জানালা দেওয়া (প্রথম অর্ডারের পর ৩০ দিন, শুধু যাঁদের ৩০ দিন পার হয়েছে), অথবা পর্ব ৫৫-এর Kaplan-Meier.

৩ · ৭ দিনের moving average — frame, খালি দিন আর ঢাকার তারিখ

দৈনিক অর্ডারের সংখ্যা খুব ওঠানামা করে, তাই প্রবণতা দেখতে moving average: প্রতিটি দিনে শেষ ৭ দিনের গড়। Frame ROWS BETWEEN 6 PRECEDING AND CURRENT ROW মানে বর্তমান সারিসহ আগের ছয়টি সারি. লক্ষ করুন, সারি; দিন নয়। যেদিন কোনো অর্ডার নেই, GROUP BY সেদিনের সারিই বানায় না। নিচে জুনের প্রথম পাঁচ দিনে ৩ সারির জানালা ধাপে ধাপে:

জুনের দৈনিক অর্ডার (ঢাকার তারিখ)৪ জুন কোনো অর্ডার নেই
 date       │ n
 2026-06-01 │ 4
 2026-06-02 │ 4
 2026-06-03 │ 1
 2026-06-04 │ 0
 2026-06-05 │ 1
বর্তমান সারি ১ জুনআগে কোনো সারি নেই
▶ 06-01 │ 4   ← frame
  06-02 │ 4
  06-03 │ 1

frame-এ ১টি সারি: 4 ÷ 1 = 4.00
বর্তমান সারি ৩ জুনজানালা প্রথমবার ভরা
  06-01 │ 4   ┐
  06-02 │ 4   │ frame
▶ 06-03 │ 1   ┘
  06-04 │ 0

(4 + 4 + 1) ÷ 3 = 3.00
বর্তমান সারি ৪ জুনশূন্যের দিনটিও গোনা হলো
  06-01 │ 4   ← বাইরে চলে গেল
  06-02 │ 4   ┐
  06-03 │ 1   │ frame
▶ 06-04 │ 0   ┘

(4 + 1 + 0) ÷ 3 = 1.67
বর্তমান সারি ৫ জুনক্যালেন্ডার ভরা থাকলে
  06-03 │ 1   ┐
  06-04 │ 0   │ frame = ৩ দিন
▶ 06-05 │ 1   ┘

(1 + 0 + 1) ÷ 3 = 0.67
শুধু GROUP BY, ৪ জুন নেইভুল জানালা
  06-02 │ 4   ┐
  06-03 │ 1   │ frame = ৪ দিন!
▶ 06-05 │ 1   ┘

(4 + 1 + 1) ÷ 3 = 2.00
(ঠিক মান 0.67)
  1. ডেটা। জুনের প্রথম পাঁচ দিনের অর্ডার, ঢাকার তারিখে: ৪, ৪, ১, ০, ১. চার তারিখে কোনো অর্ডার নেই, কিন্তু ক্যালেন্ডার ভরা থাকায় তার সারি আছে, মান ০.
  2. প্রথম সারি। 2 PRECEDING-এর জন্য আগে কোনো সারি নেই, তাই frame-এ শুধু নিজে; গড় ৪.০০. শুরুর দিকে জানালা অপূর্ণ, তাই এই মানগুলো কম নির্ভরযোগ্য।
  3. জানালা ভরল। ৩ জুনে তিনটি সারি: (৪ + ৪ + ১) ÷ ৩ = ৩.০০.
  4. এক ধাপ সরল। ১ জুন বেরিয়ে গেল, ৪ জুন ঢুকল। শূন্যের দিনটিও গড়ে আছে: (৪ + ১ + ০) ÷ ৩ = ১.৬৭.
  5. ভরা ক্যালেন্ডার। ৫ জুনে জানালা ৩, ৪ আর ৫ জুন: (১ + ০ + ১) ÷ ৩ = ০.৬৭. তিন সারি মানে ঠিক তিন দিন।
  6. খালি দিন বাদ পড়লে। শুধু GROUP BY করলে ৪ জুনের সারিই নেই, তাই ৫ জুনের “শেষ তিন সারি” হয়ে যায় ২, ৩ আর ৫ জুন: চার দিনের জানালা, গড় ২.০০. শূন্যের দিন বাদ পড়ায় গড় ফুলে উঠল।

এডিটরে একটি recursive CTE জুনের ৩০টি তারিখ বানায়, LEFT JOIN দিয়ে অর্ডার না থাকা দিনেও ০ বসায়, তারপর ৭ দিনের গড়। পাশে n_utc কলামে একই গোনা UTC-র তারিখে, আর নিচে খালি দিন বাদ রাখা ভুল পথ।

WITH RECURSIVE cal(d) AS (
  SELECT '2026-06-01'
  UNION ALL SELECT date(d, '+1 day') FROM cal WHERE d < '2026-06-30'),
daily AS (                                   -- ঢাকার তারিখে গোনা
  SELECT date(order_ts, '+6 hours') AS d, COUNT(*) AS n
  FROM orders GROUP BY 1),
utc AS (SELECT date(order_ts) AS d, COUNT(*) AS n_utc FROM orders GROUP BY 1),
filled AS (                                  -- অর্ডার নেই এমন দিনেও একটি সারি
  SELECT cal.d, COALESCE(daily.n, 0) AS n, COALESCE(utc.n_utc, 0) AS n_utc
  FROM cal LEFT JOIN daily ON daily.d = cal.d LEFT JOIN utc ON utc.d = cal.d)
SELECT d, n, n_utc,
       ROUND(AVG(n) OVER (ORDER BY d ROWS BETWEEN 6 PRECEDING AND CURRENT ROW), 2) AS ma7,
       COUNT(*) OVER (ORDER BY d ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)     AS days_used
FROM filled ORDER BY d;

-- ভুল পথ: খালি দিন বাদ পড়লে "শেষ ৭ সারি" আর "শেষ ৭ দিন" এক নয়
SELECT d, n,
       ROUND(AVG(n) OVER (ORDER BY d ROWS BETWEEN 6 PRECEDING AND CURRENT ROW), 2) AS ma7_rows
FROM (SELECT date(order_ts, '+6 hours') AS d, COUNT(*) AS n FROM orders
      WHERE order_ts >= '2026-05-31 18:00:00' GROUP BY 1)
ORDER BY d;

প্রথম ফলে ৩০টি সারি। days_used প্রথম ছয় দিনে ১ থেকে ৬, তারপর সবসময় ৭; ৭ জুনের পর থেকেই গড়টি সত্যিকারের ৭ দিনের। ৯ জুনের ma7 ০.৫৭, কারণ ৩ থেকে ৯ জুনের মধ্যে চার দিন শূন্য। দ্বিতীয় ফলে মাত্র ২০টি সারি (দশটি দিনে কোনো অর্ডার নেই), আর ৯ জুনের ma7_rows ২.৪০: চার গুণের বেশি, কারণ “শেষ ৭ সারি” পিছিয়ে ১ জুন পর্যন্ত গেছে। n আর n_utc মেলান: ১৫ জুনে ০ বনাম ১, ১৬ জুনে ২ বনাম ১, ২০–২১ জুনে ৩/৪ বনাম ৪/৩, ২৩–২৪ জুনে ১/৬ বনাম ২/৫. রাত বারোটার পরের অর্ডার UTC-তে আগের দিনে পড়েছে; মোট সমান, কিন্তু কোন দিন “ভালো” ছিল, সেই গল্প বদলে যায়।

সময়-অঞ্চলের তিন নিয়ম। (১) মুহূর্ত UTC-তে রাখুন, আর রিপোর্টের সীমানায় (দিন, সপ্তাহ, মাস) স্থানীয় সময়ে রূপান্তর করুন। (২) ফিল্টারের সীমাও রূপান্তর করুন: ঢাকার ১ জুন ০০:০০ মানে UTC-র '2026-05-31 18:00:00'. (৩) “+৬ ঘণ্টা” শুধু সেই সময়ের জন্য ঠিক, যখন নিয়ম বদলায়নি। বাংলাদেশে ২০০৯ সালের মাঝামাঝি থেকে বছরের শেষ পর্যন্ত daylight saving চালু ছিল বলে জানা যায়, তাই দীর্ঘ ইতিহাসের ডেটায় সংখ্যা যোগ না করে Asia/Dhaka নাম দিয়ে রূপান্তর করুন (pandas-এ tz_convert, AI-এর ভিত্তি কোর্সের পর্ব ২৬)।

৪ · আগে পরিষ্কার — ROW_NUMBER দিয়ে duplicate বাদ আর ৩০ মিনিটের session

মোবাইল অ্যাপ নেটওয়ার্ক দুর্বল হলে একই ঘটনা দুবার পাঠায়; সার্ভার প্রতিবার নতুন event_id দেয়। ফলে সারি দুটি পুরো এক নয়, তাই SELECT DISTINCT * এদের ধরতে পারে না। পথ হলো যে কলামগুলো “একই ঘটনা” বোঝায় (গ্রাহক, ধরন, সময়) সেগুলো দিয়ে partition করে ROW_NUMBER(), তারপর শুধু rn = 1 রাখা। ORDER BY ঠিক করে কোনটি থাকবে: এখানে প্রথমবার পাঠানোটি।

CREATE VIEW r AS
SELECT *, ROW_NUMBER() OVER (
         PARTITION BY customer_id, event_type, event_ts   -- একই ঘটনা
         ORDER BY event_id) AS rn                         -- প্রথমবার পাঠানোটি রাখি
FROM events;

-- কোন সারিগুলো দ্বিতীয়বার এসেছে?
SELECT event_id, customer_id, event_ts, event_type, rn
FROM r WHERE rn > 1 ORDER BY event_id;

-- DISTINCT * কেন কাজ করে না: event_id প্রতিটি সারিতে আলাদা
SELECT (SELECT COUNT(*) FROM events)                       AS raw_rows,
       (SELECT COUNT(*) FROM (SELECT DISTINCT * FROM events)) AS distinct_rows,
       (SELECT COUNT(*) FROM r WHERE rn = 1)                AS dedup_rows;

-- ধাপভিত্তিক গোনা: paid-এর পাশে আসল অর্ডারের সংখ্যা
SELECT event_type, COUNT(*) AS raw_rows, SUM(rn = 1) AS dedup_rows
FROM r GROUP BY event_type ORDER BY raw_rows DESC;
SELECT COUNT(*) AS june_orders FROM orders
WHERE order_ts >= '2026-05-31 18:00:00';   -- ঢাকার ১ জুন ০০:০০ = UTC-র ৩১ মে ১৮:০০

আটটি সারি দ্বিতীয়বার এসেছে (event_id ৩৭২ থেকে ৩৭৯; পাঁচটি view, একটি করে cart, checkout আর paid). তিন উপায়ে গোনা: 379 | 379 | 371. DISTINCT কিছুই বাদ দেয়নি, ROW_NUMBER আটটি বাদ দিয়েছে। শেষ দুটি ফল পাশাপাশি পড়ুন: paid ঘটনা কাঁচা অবস্থায় ৪৪টি, পরিষ্কার করলে ৪৩টি, আর orders টেবিলে জুনের অর্ডারও ঠিক ৪৩টি। পরিষ্কার না করলে “টাকা দিয়েছেন” সংখ্যাটি আসল অর্ডারের চেয়ে বেশি দেখাত। এমন duplicate মেলানোর কাজটি নিয়মিত করুন: event থেকে গোনা সংখ্যা আর লেনদেনের টেবিলের সংখ্যা মিলছে কি না।

Sessionisation মানে একজনের ঘটনাগুলোকে ভিজিটে ভাগ করা। নিয়মটি সরল: আগের ঘটনা থেকে ৩০ মিনিটের বেশি বিরতি হলে নতুন session. ৩০ মিনিট কোনো প্রাকৃতিক নিয়ম নয়; অনেক web analytics টুলের প্রচলিত default, যা ব্যবসা অনুযায়ী বদলানো যায়। SQL-এ তিন ধাপ: LAG দিয়ে বিরতি (মিনিটে, julianday-এর পার্থক্য × ১৪৪০), নতুন session শুরু হলে ১ আর না হলে ০, তারপর সেই ১-গুলোর running total-ই session নম্বর। এটিই পরিচিত “gaps and islands” কৌশল।

CREATE VIEW e AS                       -- আগের এডিটরের মতো, পুনরাবৃত্তি বাদ
SELECT customer_id, event_ts, event_type FROM (
  SELECT *, ROW_NUMBER() OVER (PARTITION BY customer_id, event_type, event_ts
                               ORDER BY event_id) AS rn FROM events)
WHERE rn = 1;

CREATE VIEW s AS
SELECT *, SUM(CASE WHEN gap_min IS NULL OR gap_min > 30 THEN 1 ELSE 0 END)
          OVER (PARTITION BY customer_id ORDER BY event_ts
                ROWS UNBOUNDED PRECEDING) AS session_no
FROM (SELECT *, ROUND((julianday(event_ts) - julianday(LAG(event_ts)
          OVER (PARTITION BY customer_id ORDER BY event_ts))) * 1440, 1) AS gap_min
      FROM e);

-- এক গ্রাহকের ঘটনা আর তাদের session
SELECT event_ts, event_type, gap_min, session_no FROM s WHERE customer_id = 7;

-- সব session-এর সারাংশ
SELECT COUNT(*) AS sessions, ROUND(AVG(n_events), 2) AS avg_events,
       SUM(has_paid) AS paid_sessions,
       ROUND(AVG(minutes), 1) AS avg_minutes
FROM (SELECT customer_id, session_no, COUNT(*) AS n_events,
             MAX(event_type = 'paid') AS has_paid,
             (julianday(MAX(event_ts)) - julianday(MIN(event_ts))) * 1440 AS minutes
      FROM s GROUP BY customer_id, session_no);

গ্রাহক ৭ ১২ জুন সন্ধ্যায় (ঢাকার সময় রাত আটটার পর) ছয়টি ঘটনা ঘটিয়েছেন। cart-এর আগে ২১.৪ মিনিটের বিরতি, তবু একই session (১); তারপর ৪৯.৪ মিনিট পর আবার view, তাই session ২. দ্বিতীয় session-এ checkout পর্যন্ত গিয়ে থেমেছেন। পুরো ডেটার সারাংশ: 129 | 2.88 | 43 | 14.1. ১২৯টি session, গড়ে ২.৮৮টি ঘটনা, ৪৩টিতে টাকা দেওয়া, আর একটি session গড়ে ১৪.১ মিনিট (প্রথম থেকে শেষ ঘটনা পর্যন্ত; শেষ ঘটনার পর কতক্ষণ ছিলেন, তা event থেকে জানা যায় না)। বিরতির সীমা ৩০ থেকে ২০ বা ৬০ করে চালিয়ে দেখুন session-এর সংখ্যা কতটা নড়ে; যে metric সীমার ওপর খুব নির্ভর করে, তার সংজ্ঞা লিখে রাখতে হয়।

৫ · Funnel — view থেকে paid পর্যন্ত কোথায় ঝরে পড়েন

নিউমার্কেটে একশো জন দোকানে ঢুকলেন, ষাটজন একটি শাড়ি হাতে নিলেন, চল্লিশজন দাম জিজ্ঞেস করলেন, পঁচিশজন কিনলেন। দোকানি জানতে চান কোন ধাপে সবচেয়ে বেশি মানুষ ফিরে যান, কারণ সেখানেই উন্নতির জায়গা। এটিই funnel: ধাপে ধাপে কতজন পরের ধাপে পৌঁছান।

দুটি সংখ্যা লাগে। Step conversion = এই ধাপের মানুষ ÷ আগের ধাপের মানুষ; overall conversion = এই ধাপ ÷ প্রথম ধাপ। Overall হলো সব step conversion-এর গুণফল। নিচের কোয়েরি গ্রাহক-স্তরের, ক্রম মানা funnel বানায়: প্রতিটি ধাপের প্রথম সময় বের করে শর্ত দেয় যে cart এসেছে view-এর পরে, checkout cart-এর পরে, ইত্যাদি। ধাপগুলো UNION ALL দিয়ে সারি বানিয়ে LAG আর FIRST_VALUE দিয়ে দুই হার।

CREATE VIEW f AS                       -- প্রতি গ্রাহক: প্রতিটি ধাপের প্রথম সময়
SELECT customer_id,
       MIN(CASE WHEN event_type = 'view'     THEN event_ts END) AS t_view,
       MIN(CASE WHEN event_type = 'cart'     THEN event_ts END) AS t_cart,
       MIN(CASE WHEN event_type = 'checkout' THEN event_ts END) AS t_check,
       MIN(CASE WHEN event_type = 'paid'     THEN event_ts END) AS t_paid
FROM events GROUP BY customer_id;

WITH steps AS (                        -- ক্রম মেনে পৌঁছানো গ্রাহক
  SELECT 1 AS step, 'view' AS name, COUNT(t_view) AS users FROM f
  UNION ALL SELECT 2, 'cart', COUNT(*) FROM f WHERE t_cart >= t_view
  UNION ALL SELECT 3, 'checkout', COUNT(*) FROM f
            WHERE t_check >= t_cart AND t_cart >= t_view
  UNION ALL SELECT 4, 'paid', COUNT(*) FROM f
            WHERE t_paid >= t_check AND t_check >= t_cart AND t_cart >= t_view)
SELECT step, name, users,
       ROUND(100.0 * users / LAG(users) OVER (ORDER BY step), 1)         AS step_pct,
       ROUND(100.0 * users / FIRST_VALUE(users) OVER (ORDER BY step), 1) AS overall_pct
FROM steps ORDER BY step;

-- ধাপের মাঝের সময় (যাঁরা টাকা দিয়েছেন), মিনিটে
SELECT COUNT(*) AS payers,
       ROUND(AVG((julianday(t_check) - julianday(t_cart)) * 1440), 1) AS cart_to_check,
       ROUND(AVG((julianday(t_paid) - julianday(t_check)) * 1440), 1) AS check_to_paid,
       ROUND(AVG((julianday(t_paid) - julianday(t_view)) * 24), 1)    AS view_to_paid_h
FROM f WHERE t_paid IS NOT NULL;

Funnel: ৬৯ → ৪৫ → ২৯ → ২২ জন। Step conversion ৬৫.২%, ৬৪.৪% আর ৭৫.৯%; overall ৩১.৯%. যাচাই: ০.৬৫২ × ০.৬৪৪ × ০.৭৫৯ ≈ ০.৩১৯. সবচেয়ে বড় ক্ষতি cart থেকে checkout-এ (৪৫ থেকে ২৯)। এখানে duplicate বাদ না দিলেও চলেছে, কারণ MIN আর COUNT গ্রাহক গোনে, ঘটনা নয়। এবার সময়ের ফল: 22 | 1371.1 | 6.7 | 51.1. checkout থেকে paid গড়ে ৬.৭ মিনিট, কিন্তু cart থেকে checkout গড়ে ১,৩৭১ মিনিট (প্রায় এক দিন)! কারণ গ্রাহক-স্তরের funnel ভিজিট মিশিয়ে ফেলে: যেমন কেউ একদিন cart করে চলে গেলেন, কয়েক দিন পরে এসে checkout করলেন; প্রথম cart আর প্রথম checkout তখন দুটি আলাদা ভিজিটের। গড়ও কয়েকটি বড় মানে টেনে ওঠে; সময়ের জন্য median বেশি অর্থবহ।

গ্রাহক-স্তর না session-স্তর, তা প্রশ্ন ঠিক করে। “জুনে কত শতাংশ দর্শক শেষ পর্যন্ত কিনলেন” প্রশ্নে গ্রাহক-স্তর; “একটি ভিজিটে checkout পাতা কতটা কাজ করে” প্রশ্নে session-স্তর। অনুশীলনের ৪ নম্বরে session-স্তরের funnel নিজে লিখবেন। আর মনে রাখুন, funnel বলে কোথায় ঝরে পড়ছেন, কেন নয়। কারণ খুঁজতে পরিবর্তন করে মাপতে হয়: পর্ব ৬২ (A/B test-এর পরিকল্পনা — power, sample size আর peeking-এর ফাঁদ)-এ সেই কাজ।

৬ · Cohort retention টেবিল — যোগ দেওয়ার মাস × কত মাস পরে

“এই মাসে ৫৪ জন ক্রেতা” সংখ্যাটি নতুন আর পুরোনো গ্রাহক মিশিয়ে দেয়; নতুন গ্রাহক দ্রুত এলে পুরোনোদের চলে যাওয়া ঢাকা পড়ে। Cohort analysis গ্রাহকদের যোগ দেওয়ার মাস দিয়ে দলে ভাগ করে, আর প্রতিটি দলের জন্য আলাদা করে দেখে: যোগ দেওয়ার মাসে (M0), এক মাস পরে (M1), দুই মাস পরে (M2)… কত শতাংশ কিনেছেন। এই শতাংশই retention rate. একটি সারি হাতে বানানো যাক:

২০২৬-০১ cohort (৩০ জন) · প্রতিটি ধাপে টেবিলে যা যোগ হলো
কী করছি
হিসাব
সারি
  1. হর। Cohort-এর আকার হলো সেই মাসে যোগ দেওয়া সবাই, যাঁরা কখনো কেনেননি তাঁরাও। শুধু ক্রেতাদের দিয়ে ভাগ করলে retention ফুলে ওঠে।
  2. (গ্রাহক, m) জোড়া। প্রতিটি delivered অর্ডার থেকে যোগ দেওয়ার মাস আর কেনার মাসের ব্যবধান m, ঢাকার সময়ে। DISTINCT দরকার: ফেব্রুয়ারিতে তিনবার কেনা গ্রাহক M1-এ একজনই।
  3. M0. ২৬ জন জানুয়ারিতেই কিনেছেন: ২৬ ÷ ৩০ = ৮৬.৭%.
  4. M1, M2. ১০ ÷ ৩০ = ৩৩.৩% আর ৯ ÷ ৩০ = ৩০.০%. এগুলো ভিন্ন ভিন্ন মানুষ হতে পারেন; M2-এর কেউ M1-এ না-ও কিনে থাকতে পারেন।
  5. M3 থেকে M5. ৫, ৩ আর ২ জন: ১৬.৭%, ১০.০% আর ৬.৭%. জানুয়ারির cohort ছয় মাস দেখা গেছে, তাই M5 পর্যন্ত ঘর ভরা।
  6. দেখা হয়নি এমন ঘর। এপ্রিলের cohort-এর M3 হলো জুলাই, যা ডেটায় এখনও আসেনি। সেখানে ০ লিখলে মনে হবে সবাই চলে গেছেন; ঠিক উত্তর NULL (ফাঁকা)। এটিও right censoring.

এডিটরে প্রথমে একটি view প্রতিটি গ্রাহকের সক্রিয় মাসগুলো বের করে; তারপর pivot: SUM(a.m = k) প্রতিটি k-এর জন্য একটি কলাম। CASE দিয়ে না-দেখা মাস NULL রাখা হয়েছে।

CREATE VIEW act AS                     -- কে কোন মাসে কিনেছেন (ঢাকার সময়ে)
SELECT DISTINCT c.customer_id, substr(c.signup_date, 1, 7) AS cohort,
       (CAST(strftime('%Y', o.order_ts, '+6 hours') AS INTEGER)
          - CAST(substr(c.signup_date, 1, 4) AS INTEGER)) * 12
     + CAST(strftime('%m', o.order_ts, '+6 hours') AS INTEGER)
          - CAST(substr(c.signup_date, 6, 2) AS INTEGER) AS m
FROM customers c JOIN orders o ON o.customer_id = c.customer_id
WHERE o.status = 'delivered';

WITH size AS (SELECT substr(signup_date, 1, 7) AS cohort, COUNT(*) AS n
              FROM customers GROUP BY 1)
SELECT s.cohort, s.n,
  ROUND(100.0 * SUM(a.m = 0) / s.n, 1) AS m0,
  ROUND(100.0 * SUM(a.m = 1) / s.n, 1) AS m1,
  ROUND(100.0 * SUM(a.m = 2) / s.n, 1) AS m2,
  CASE WHEN date(s.cohort || '-01', '+3 months') < '2026-07-01'   -- মাসটি কি
       THEN ROUND(100.0 * SUM(a.m = 3) / s.n, 1) END AS m3,       -- দেখা হয়েছে?
  CASE WHEN date(s.cohort || '-01', '+4 months') < '2026-07-01'
       THEN ROUND(100.0 * SUM(a.m = 4) / s.n, 1) END AS m4
FROM size s LEFT JOIN act a ON a.cohort = s.cohort
GROUP BY s.cohort ORDER BY s.cohort;

চারটি সারি: 2026-01 | 30 | 86.7 | 33.3 | 30 | 16.7 | 10, 2026-02 | 30 | 63.3 | 36.7 | 16.7 | 23.3 | 23.3, 2026-03 | 30 | 86.7 | 50 | 26.7 | 16.7 | NULL, 2026-04 | 30 | 86.7 | 20 | 26.7 | NULL | NULL. তিনভাবে পড়ুন। সারি ধরে: একটি দল সময়ের সঙ্গে কেমন ধরে রাখছে। কলাম ধরে: একই বয়সে নতুন দলগুলো ভালো না খারাপ; M1-এ মার্চ ৫০.০%, এপ্রিল ২০.০%. কোনাকুনি: একই ক্যালেন্ডার মাস (যেমন জুন: জানুয়ারির M5, ফেব্রুয়ারির M4, মার্চের M3, এপ্রিলের M2), যেখানে কোনো ঘটনা (দাম বদল, ঈদ) সব দলে একসঙ্গে দেখা দেয়। ফেব্রুয়ারির M0 মাত্র ৬৩.৩% কেন? ফেব্রুয়ারি ছোট মাস; মাসের শেষে যোগ দেওয়া অনেকের প্রথম অর্ডার মার্চে পড়েছে। ক্যালেন্ডার মাসের cohort এই সীমানা-প্রভাবে কাঁপে; বিকল্প হলো “যোগ দেওয়ার পর ০–২৯ দিন, ৩০–৫৯ দিন” ধরনের জানালা। ৩০ জনের দলে এক জন মানে ৩.৩ শতাংশ বিন্দু, তাই ছোট পার্থক্যকে প্রবণতা ভাববেন না।

Cohort টেবিল retention-এর ছবি দেয়; কোনো গ্রাহক কখন চলে যেতে পারেন, সেই প্রশ্ন পর্ব ৫৫-এর survival analysis-এর, আর retention curve, RFM আর lifetime value আসবে পর্ব ৬৫ (Customer analytics — RFM, retention, churn আর lifetime value)-এ।

৭ · Percentile — SQLite-এ PERCENTILE_CONT নেই, তবু median

PostgreSQL-এ median লেখা যায় PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY x) দিয়ে। SQLite-এর সাধারণ build-এ এমন কোনো ফাংশন নেই। SQLite 3.47 থেকে একটি ঐচ্ছিক percentile extension আছে, কিন্তু সেটি চালু করে compile না করলে পাওয়া যায় না; এই পাতার sql.js-এ (SQLite 3.49.1) percentile_cont চেষ্টা করলে “no such function” আসে। তাই window function দিয়ে নিজে বানাতে হয়।

PERCENTILE_CONT-এর নিয়ম: n টি মান ছোট থেকে বড় সাজান, তারপর p-তম percentile-এর অবস্থান

pos = 1 + (n − 1) × p

pos পূর্ণসংখ্যা হলে সেই অবস্থানের মান। না হলে নিচের মান xlo আর ওপরের xhi-এর মাঝে রৈখিক মিশ্রণ: xlo + (pos − ⌊pos⌋) × (xhi − xlo). এখানে ⌊pos⌋ মানে pos-এর পূর্ণসংখ্যা অংশ। pandas-এর quantile()-এর default (linear) ঠিক এই নিয়ম। নিচে পরপর দুই অর্ডারের মাঝের দিনের ২৬২টি মানের median আর p90:

CREATE VIEW v AS                       -- পরপর দুই অর্ডারের মাঝের দিন
SELECT x FROM (
  SELECT julianday(order_ts) - julianday(LAG(order_ts) OVER (
           PARTITION BY customer_id ORDER BY order_ts)) AS x
  FROM orders WHERE status = 'delivered')
WHERE x IS NOT NULL;

-- PERCENTILE_CONT-এর মতো: সাজানো ক্রমে দুই প্রতিবেশীর মাঝে রৈখিক মিশ্রণ
WITH r AS (SELECT x, ROW_NUMBER() OVER (ORDER BY x) AS rn,
                  COUNT(*) OVER () AS n FROM v),
q AS (SELECT 0.5 AS p UNION ALL SELECT 0.9),
h AS (SELECT p, 1 + (n - 1) * p AS pos FROM q, (SELECT MAX(n) AS n FROM r))
SELECT h.p, ROUND(h.pos, 2) AS pos, ROUND(lo.x, 2) AS x_lo, ROUND(hi.x, 2) AS x_hi,
       ROUND(lo.x + (h.pos - CAST(h.pos AS INTEGER)) * (hi.x - lo.x), 2) AS pct_cont
FROM h
JOIN r lo ON lo.rn = CAST(h.pos AS INTEGER)
JOIN r hi ON hi.rn = CAST(h.pos AS INTEGER) + 1;

-- গড়ের পাশে median: median() শুধু এই পাতার sql.js-এর বাড়তি ফাংশন
SELECT COUNT(*) AS n, ROUND(AVG(x), 2) AS mean_gap, ROUND(median(x), 2) AS median_gap FROM v;
-- NTILE(4): সারি গুনে চারটি প্রায় সমান ভাগ
SELECT quartile, COUNT(*) AS n, ROUND(MIN(x), 1) AS lo, ROUND(MAX(x), 1) AS hi
FROM (SELECT x, NTILE(4) OVER (ORDER BY x) AS quartile FROM v)
GROUP BY quartile;

Median: n = ২৬২, pos = ১ + ২৬১ × ০.৫ = ১৩১.৫, অর্থাৎ ১৩১ আর ১৩২ নম্বর মানের ঠিক মাঝখানে: ৯.১৭ + ০.৫ × (৯.৪৫ − ৯.১৭) = ৯.৩১ দিন। p90: pos = ২৩৫.৯, তাই ২৭.৭৫ + ০.৯ × ০.১৮ ≈ ২৭.৯১ দিন। পরের সারি: 262 | 13.91 | 9.31. গড় ১৩.৯১ কিন্তু median ৯.৩১: কয়েকটি খুব লম্বা বিরতি (সর্বোচ্চ ১১৪.৪ দিন) গড়কে টেনে তুলেছে। “সাধারণ” গ্রাহকের কথা বলতে median, আর “দশজনে নয়জন কতদিনের মধ্যে ফেরেন” বলতে p90. median() এখানে কাজ করছে কারণ sql.js একটি বাড়তি ফাংশনের সংগ্রহ যোগ করে compile করা; Python-এর sqlite3-এ (পাতার Pyodide-এ SQLite 3.39.0) একই লাইন “no such function” দেয়। NTILE(4)-এর ভাগ ৬৬, ৬৬, ৬৫ আর ৬৫টি মান; NTILE সারি গোনে, তাই সমান মান দুই ভাগে পড়তে পারে, আর ভাগের সীমা আসল quartile-এর থেকে একটু আলাদা হয়।

SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY x) AS median_gap,
       percentile_cont(0.9) WITHIN GROUP (ORDER BY x) AS p90_gap,
       percentile_disc(0.5) WITHIN GROUP (ORDER BY x) AS median_disc
FROM v;

percentile_disc মিশ্রণ করে না, আসল একটি মান দেয়; ডাটাবেস ও SQL কোর্সের পর্ব ২৭-এ nearest-rank পদ্ধতিও আছে। কোন সংজ্ঞা ব্যবহার করলেন, রিপোর্টে লিখে দিন; ছোট ডেটায় তিন পদ্ধতির উত্তর আলাদা হয়।

৮ · নীরব ফাঁদ — fan-out join, NULL-এর গড় আর COUNT(DISTINCT)

সবচেয়ে বিপজ্জনক SQL ভুল error দেয় না, শুধু ভুল সংখ্যা দেয়। প্রথমটি fan-out: এক → অনেক join-এর পর “এক” দিকের প্রতিটি সারি একাধিকবার আসে। দুটি আসল অর্ডার দিয়ে দেখুন:

orders (দুটি সারি)আসল fee = 180
 order_id │ delivery_fee
 1006     │  60
 1009     │ 120
order_items1006-এর তিনটি লাইন
 order_id │ qty × price
 1006     │ 1 × 2400
 1006     │ 2 × 550
 1006     │ 2 × 420
 1009     │ 1 × 2400
orders JOIN order_items৪টি সারি
 order │ fee │ line
 1006  │  60 │ 2400
 1006  │  60 │ 1100
 1006  │  60 │  840
 1009  │ 120 │ 2400
SUM করলেএকটি ঠিক, একটি ভুল
SUM(line) = 2400 + 1100
          + 840 + 2400
          = 6740  ✓

SUM(fee)  = 60 + 60 + 60 + 120
          = 300  ✗ (আসল 180)
আগে গুটিয়ে, তারপর joinপ্রতি অর্ডার এক সারি
 order │ fee │ items
 1006  │  60 │ 4340
 1009  │ 120 │ 2400

SUM(fee)   = 180  ✓
SUM(items) = 6740 ✓
  1. অর্ডার-স্তর। delivery fee অর্ডারপ্রতি একবার: ১০০৬-এ ৬০, ১০০৯-এ ১২০, মোট ১৮০ টাকা।
  2. লাইন-স্তর। অর্ডার ১০০৬-এ তিন রকম পণ্য, তাই order_items-এ তিনটি সারি; ১০০৯-এ একটি।
  3. Join. প্রতিটি লাইনের পাশে তার অর্ডারের সব কলাম বসে। ১০০৬-এর fee ৬০ এখন তিনবার আছে। কোনো error নেই, সারিগুলো দেখতেও স্বাভাবিক।
  4. যোগফল। লাইনের মূল্য লাইন-স্তরের, তাই তার যোগ ঠিক (৬,৭৪০)। কিন্তু fee অর্ডার-স্তরের; তার যোগ ৩০০, যা আসলের ১.৬৭ গুণ। যে অর্ডারে যত বেশি লাইন, তার fee তত বেশিবার গোনা।
  5. প্রতিকার। লাইনগুলো আগে অর্ডার-স্তরে গুটিয়ে নিন (একটি CTE-তে GROUP BY order_id), তারপর join. এখন প্রতি অর্ডার এক সারি, তাই দুটি যোগফলই ঠিক।
-- ভুল: অর্ডার-স্তরের delivery_fee প্রতিটি item-সারিতে একবার করে যোগ হয়
SELECT COUNT(*)                   AS joined_rows,
       SUM(o.delivery_fee)        AS fee_total,
       SUM(i.qty * i.unit_price)  AS items_total
FROM orders o JOIN order_items i ON i.order_id = o.order_id
WHERE o.status = 'delivered';

-- ঠিক: আগে item-কে অর্ডার-স্তরে গুটিয়ে নিন, তারপর join
WITH it AS (SELECT order_id, SUM(qty * unit_price) AS items
            FROM order_items GROUP BY order_id)
SELECT COUNT(*)            AS orders,
       SUM(o.delivery_fee) AS fee_total,
       SUM(it.items)       AS items_total
FROM orders o JOIN it ON it.order_id = o.order_id
WHERE o.status = 'delivered';

-- NULL আর গড়: AVG শুধু non-NULL মান গোনে
SELECT COUNT(*) AS orders, COUNT(discount) AS with_coupon,
       ROUND(AVG(discount), 1)              AS avg_if_used,
       ROUND(AVG(COALESCE(discount, 0)), 1) AS avg_per_order
FROM orders WHERE status = 'delivered';

প্রথম ফল: 583 | 57360 | 560470. দ্বিতীয়: 367 | 35880 | 560470. ৩৬৭টি delivered অর্ডার join-এর পর ৫৮৩টি সারি; পণ্যের মোট দুই পথেই ৫,৬০,৪৭০ টাকা, কিন্তু delivery fee ভুল পথে ৫৭,৩৬০, ঠিক পথে ৩৫,৮৮০ টাকা: প্রায় ৬০% বেশি। ধরার সহজ কৌশল: join-এর আগে আর পরে COUNT(*) আর COUNT(DISTINCT order_id) মেলান। তৃতীয় ফল: 367 | 71 | 96.5 | 18.7. AVG(discount) শুধু কুপন ব্যবহৃত ৭১টি অর্ডারের গড় (৯৬.৫ টাকা), কারণ aggregate ফাংশন NULL বাদ দেয়। “অর্ডারপ্রতি গড় ছাড়” চাইলে COALESCE দিয়ে NULL-কে ০ করতে হয়: ১৮.৭ টাকা। কোনটি ঠিক, তা প্রশ্নের ওপর নির্ভর করে; কিন্তু NULL মানে “কুপন নেই” না “তথ্য হারিয়েছে”, তা আগে জেনে নিন।

COUNT(DISTINCT)-এর দুটি দাম। প্রথমত খরচ: আলাদা মান গুনতে ডাটাবেসকে সব মান সাজাতে বা hash টেবিলে রাখতে হয়, যা কোটি সারিতে ধীর আর মেমরি-খেকো। বড় warehouse-এ তাই আনুমানিক গোনার ফাংশন থাকে (যেমন BigQuery-র APPROX_COUNT_DISTINCT, HyperLogLog-ধরনের অ্যালগরিদমে), যা অল্প ভুলের বিনিময়ে অনেক দ্রুত। দ্বিতীয়ত, distinct গোনা যোগ করা যায় না: জুনের ৩০ দিনের “দৈনিক সক্রিয় গ্রাহক” যোগ করলে মাসিক সক্রিয় গ্রাহক হয় না, কারণ একজন অনেক দিন আসতে পারেন। মাসিক সংখ্যা চাইলে মাসের ওপর আবার DISTINCT গুনতে হয়।

৯ · SQL থেকে pandas — একই সংখ্যা দুই পথে, আর model-এর feature

গুরুত্বপূর্ণ সংখ্যা দুটি স্বাধীন পথে বানিয়ে মেলানো একটি ভালো অভ্যাস: SQL-এ একটি ভুল (যেমন fan-out) pandas-এ সাধারণত একইভাবে ঘটে না। প্রথম প্রশ্ন ছিল, পাতার Python-এ (Pyodide) sqlite3 চলে কি না। চলে, কোনো warning ছাড়াই; তাই একই bazar.sql একটি in-memory ডাটাবেসে তুলে pd.read_sql দিয়ে টেবিল আর SQL-এর cohort ফল pandas-এ আনা হয়েছে (SQL থেকে pandas-এ পড়ার মূল কথা AI-এর ভিত্তি কোর্সের পর্ব ২১-এ)। চারটি ব্লক একই namespace ভাগ করে; পরের ব্লক আগে চালালে আগেরগুলো নিজে থেকে আগে চলে।

import sqlite3
import pandas as pd

con = sqlite3.connect(":memory:")              # ব্রাউজারের Python-এর ভেতরে SQLite
con.executescript(open("bazar.sql", encoding="utf-8").read())
print("SQLite", sqlite3.sqlite_version)

customers = pd.read_sql("SELECT * FROM customers", con)
orders = pd.read_sql("SELECT * FROM orders", con)
events = pd.read_sql("SELECT * FROM events ORDER BY event_id", con)
print("সারি:", len(customers), len(orders), len(events))

sql_cohort = pd.read_sql("""
WITH act AS (
  SELECT DISTINCT c.customer_id, substr(c.signup_date, 1, 7) AS cohort,
    (CAST(strftime('%Y', o.order_ts, '+6 hours') AS INTEGER)
       - CAST(substr(c.signup_date, 1, 4) AS INTEGER)) * 12
    + CAST(strftime('%m', o.order_ts, '+6 hours') AS INTEGER)
       - CAST(substr(c.signup_date, 6, 2) AS INTEGER) AS m
  FROM customers c JOIN orders o ON o.customer_id = c.customer_id
  WHERE o.status = 'delivered')
SELECT a.cohort, a.m, COUNT(*) AS active, s.n
FROM act a JOIN (SELECT substr(signup_date, 1, 7) AS cohort, COUNT(*) AS n
                 FROM customers GROUP BY 1) s ON s.cohort = a.cohort
GROUP BY a.cohort, a.m""", con)
print(sql_cohort.head(3).to_string(index=False))
sql_tab = (100 * sql_cohort.active / sql_cohort.n).round(1).to_frame("pct") \
    .join(sql_cohort[["cohort", "m"]]).pivot(index="cohort", columns="m", values="pct")
print(sql_tab)

আসল আউটপুট (ব্রাউজারের Python):

SQLite 3.39.0
সারি: 120 390 379
 cohort  m  active  n
2026-01  0      26 30
2026-01  1      10 30
2026-01  2       9 30
m           0     1     2     3     4    5
cohort                                    
2026-01  86.7  33.3  30.0  16.7  10.0  6.7
2026-02  63.3  36.7  16.7  23.3  23.3  NaN
2026-03  86.7  50.0  26.7  16.7   NaN  NaN
2026-04  86.7  20.0  26.7   NaN   NaN  NaN

পাতার Python-এর SQLite 3.39.0, sql.js-এর চেয়ে পুরোনো, তবে window function (৩.২৫ থেকে আছে) দুটিতেই চলে। কোয়েরি এবার “লম্বা” আকারে (cohort, m, active) ফল দিয়েছে, আর pandas-এর pivot তাকে চওড়া টেবিল বানিয়েছে। এখানে M5 কলামও আছে: জানুয়ারির ৬.৭%. না-দেখা ঘরগুলো NaN, কারণ লম্বা ফলে সেই সারিই নেই। সাবধান: কোনো দেখা মাসে কেউ না কিনলে সেই ঘরও NaN হতো, যা আসলে ০; পরের ব্লক সেটি আলাদা করে।

এবার একই cohort টেবিল শুধু pandas দিয়ে, কাঁচা টেবিল থেকে। সময় রূপান্তর করা হয়েছে Asia/Dhaka নাম দিয়ে (পাতার Python-এ এর জন্য import tzdata লাগে), না-দেখা মাস NaN আর দেখা মাসের খালি ঘর ০. তারপর দুটি টেবিল মেলানো আর একটি retention heatmap; চিত্রের লেখা ইংরেজিতে।

import tzdata                                   # পাতার Python-এ সময়-অঞ্চলের তথ্য
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

o = orders[orders.status == "delivered"].copy()
o["ts"] = pd.to_datetime(o.order_ts).dt.tz_localize("UTC").dt.tz_convert("Asia/Dhaka")
d = o.merge(customers, on="customer_id")
sd = pd.to_datetime(d.signup_date)
d["cohort"] = d.signup_date.str[:7]
d["m"] = (d.ts.dt.year - sd.dt.year) * 12 + d.ts.dt.month - sd.dt.month

size = customers.groupby(customers.signup_date.str[:7]).size()
active = d.groupby(["cohort", "m"]).customer_id.nunique().unstack()
pd_tab = (100 * active.div(size, axis=0)).round(1)
last_m = {c: 6 - int(c[5:]) for c in pd_tab.index}   # জুন পর্যন্ত কত মাস দেখা গেছে
for c, k in last_m.items():                            # দেখা মাসে কেনা নেই = 0, না-দেখা = NaN
    pd_tab.loc[c, [m for m in pd_tab.columns if m <= k]] = pd_tab.loc[c].fillna(0)
print(pd_tab)
print("SQL আর pandas মিলেছে:", np.allclose(pd_tab, sql_tab, equal_nan=True))

fig, ax = plt.subplots(figsize=(6.4, 3.2))
im = ax.imshow(pd_tab.values, cmap="Blues", vmin=0, vmax=100)
for i in range(pd_tab.shape[0]):
    for j in range(pd_tab.shape[1]):
        v = pd_tab.values[i, j]
        if not np.isnan(v):
            ax.text(j, i, f"{v:.0f}%", ha="center", va="center", color="white" if v > 60 else "black")
ax.set_xticks(range(pd_tab.shape[1]), [f"M{m}" for m in pd_tab.columns])
ax.set_yticks(range(pd_tab.shape[0]), pd_tab.index)
ax.set_xlabel("Months since signup month"); ax.set_ylabel("Signup cohort")
ax.set_title("Monthly retention (% of cohort who ordered)")
fig.colorbar(im, ax=ax, label="% active")
fig.subplots_adjust(left=0.18, right=0.95, bottom=0.18, top=0.88)

আসল আউটপুট (ব্রাউজারের Python):

m           0     1     2     3     4    5
cohort                                    
2026-01  86.7  33.3  30.0  16.7  10.0  6.7
2026-02  63.3  36.7  16.7  23.3  23.3  NaN
2026-03  86.7  50.0  26.7  16.7   NaN  NaN
2026-04  86.7  20.0  26.7   NaN   NaN  NaN
SQL আর pandas মিলেছে: True

দুই পথের টেবিল হুবহু মিলেছে (np.allclose, NaN-কে NaN ধরে)। Heatmap-এ প্রথম কলাম গাঢ়, তারপর রং দ্রুত হালকা হয়; নিচের ডানের ফাঁকা ত্রিভুজ হলো এখনও না-আসা মাস। এই আকৃতি প্রায় সব cohort টেবিলে দেখা যায়। Heatmap কখন ভালো আর কখন লাইন-চার্ট (প্রতিটি cohort একটি retention curve), সেই বাছাই AI-এর ভিত্তি কোর্সের পর্ব ২৯-এ।

তৃতীয় ব্লক funnel আর session pandas-এ: drop_duplicates ROW_NUMBER-এর কাজ করে (প্রথম সারি রাখে, আর সারিগুলো event_id-এর ক্রমে পড়া), pivot_table প্রতিটি ধাপের প্রথম সময় দেয়, আর diff ও cumsum LAG ও running total-এর জায়গা নেয়।

import pandas as pd

ev = events.drop_duplicates(["customer_id", "event_type", "event_ts"])   # প্রথমটি থাকে
ev = ev.assign(t=pd.to_datetime(ev.event_ts))
first = ev.pivot_table(index="customer_id", columns="event_type", values="t", aggfunc="min")
view = first["view"].notna()
cart = view & (first["cart"] >= first["view"])
check = cart & (first["checkout"] >= first["cart"])
paid = check & (first["paid"] >= first["checkout"])
users = pd.Series([view.sum(), cart.sum(), check.sum(), paid.sum()],
                  index=["view", "cart", "checkout", "paid"])
print(pd.DataFrame({"users": users,
                    "step_pct": (100 * users / users.shift()).round(1),
                    "overall_pct": (100 * users / users.iloc[0]).round(1)}))

ev = ev.sort_values(["customer_id", "t"])
gap = ev.groupby("customer_id").t.diff()
new = gap.isna() | (gap > pd.Timedelta(minutes=30))     # নতুন session শুরু
ev["session_no"] = new.astype(int).groupby(ev.customer_id).cumsum()
print("session:", ev.groupby(["customer_id", "session_no"]).ngroups)

আসল আউটপুট (ব্রাউজারের Python):

          users  step_pct  overall_pct
view         69       NaN        100.0
cart         45      65.2         65.2
checkout     29      64.4         42.0
paid         22      75.9         31.9
session: 129

৬৯ → ৪৫ → ২৯ → ২২, হার ৬৫.২, ৬৪.৪ আর ৭৫.৯%, আর ১২৯টি session: SQL-এর সঙ্গে হুবহু এক। একটি সূক্ষ্ম পার্থক্য খেয়াল করুন: pandas-এ NaT-এর সঙ্গে তুলনা False দেয়, SQL-এ NULL-এর সঙ্গে তুলনা NULL দেয় আর WHERE তাকে বাদ দেয়; দুটি পথে ফল এক, কিন্তু কারণ আলাদা। এমন জায়গাতেই দুই পথ মাঝে মাঝে আলাদা হয়, তাই মেলানো জরুরি।

শেষে model-এর জন্য ডেটা। প্রশ্ন: ৩১ মে পর্যন্ত আচরণ দেখে বলা যায় কি, কে জুনে কিনবেন? মূল নিয়ম একটি cutoff: feature শুধু cutoff-এর আগের ডেটা থেকে, আর label শুধু পরের label window থেকে। এই রেখা ভাঙলে data leakage, যার বিস্তারিত AI-এর ভিত্তি কোর্সের পর্ব ৫৬-এ। SQL-এ window function দিয়ে feature বানিয়ে, cutoff-কে parameter (:cut) হিসেবে দিয়ে, সরাসরি DataFrame-এ:

import pandas as pd

cut = "2026-05-31 18:00:00"          # ঢাকার ১ জুন ০০:০০, UTC-তে
feat = pd.read_sql("""
WITH o AS (
  SELECT customer_id, order_ts,
         julianday(order_ts) - julianday(LAG(order_ts) OVER (
           PARTITION BY customer_id ORDER BY order_ts)) AS gap
  FROM orders WHERE status = 'delivered' AND order_ts < :cut)
SELECT c.customer_id, c.district,
       COUNT(o.order_ts)                                         AS frequency,
       ROUND(julianday(:cut) - julianday(MAX(o.order_ts)), 1)    AS recency_days,
       ROUND(AVG(o.gap), 1)                                      AS avg_gap,
       EXISTS (SELECT 1 FROM orders x WHERE x.customer_id = c.customer_id
               AND x.order_ts >= :cut)                           AS bought_june
FROM customers c LEFT JOIN o ON o.customer_id = c.customer_id
GROUP BY c.customer_id""", con, params={"cut": cut})
print(feat.head(4).to_string(index=False))
print(feat.dtypes.to_dict())

feat["recency_band"] = pd.cut(feat.recency_days, [0, 14, 30, 60, 200],
                              labels=["0-14", "15-30", "31-60", "60+"])
print(feat.groupby("recency_band", observed=True).bought_june.agg(["size", "mean"]).round(3))
print("কখনো কেনেননি:", feat.recency_days.isna().sum(),
      "| তাঁদের জুনে কেনার হার:", feat[feat.recency_days.isna()].bought_june.mean().round(3))

আসল আউটপুট (ব্রাউজারের Python):

 customer_id district  frequency  recency_days  avg_gap  bought_june
           1   Khulna          0           NaN      NaN            0
           2   Sylhet          4          82.4     18.6            0
           3    Dhaka          1         145.4      NaN            0
           4 Rajshahi          1         145.0      NaN            0
{'customer_id': dtype('int64'), 'district': <StringDtype(storage='python', na_value=nan)>, 'frequency': dtype('int64'), 'recency_days': dtype('float64'), 'avg_gap': dtype('float64'), 'bought_june': dtype('int64')}
              size   mean
recency_band             
0-14            20  0.750
15-30            4  0.500
31-60           34  0.088
60+             46  0.022
কখনো কেনেননি: 16 | তাঁদের জুনে কেনার হার: 0.062

১২০ জনের প্রতিজনের একটি সারি; LEFT JOIN থাকায় যিনি কখনো কেনেননি (গ্রাহক ১) তিনিও আছেন, frequency ০ আর recency NaN. টেবিলের ধরন pandas-এ সরাসরি এসেছে (জেলা string, সংখ্যা float বা int). শেষ টেবিলটি এক বাক্যে model-এর গল্প বলে: শেষ কেনা ১৪ দিনের মধ্যে হলে জুনে কেনার হার ৭৫%, ৬০ দিনের বেশি হলে ২.২%. Recency খুব শক্তিশালী feature; তবে মাঝের দলগুলো ছোট (১৫–৩০ দিনে মাত্র ৪ জন), তাই এই হারগুলো নড়বড়ে। এই feature-গুলো দিয়ে আসল churn model, RFM আর lifetime value পর্ব ৬৫-এ।

আরও গভীরে: Window function মূল SQL মানে আসে SQL:2003-এ (তার আগে SQL:1999-এর একটি OLAP সংশোধনী হিসেবে); SQLite-এ এসেছে ৩.২৫.০ (২০১৮)-তে। ডাটাবেস সাধারণত partition আর ORDER BY অনুযায়ী একবার সাজিয়ে সব window হিসাব করে, তাই একই OVER-এর অনেক ফাংশন প্রায় একটি sort-এর দামে চলে; আলাদা আলাদা OVER মানে আলাদা sort. আনুমানিক distinct গোনার ভিত্তি Flajolet ও সহকর্মীদের HyperLogLog (২০০৭)। আর “একই ক্লিক দুবার” সমস্যার গভীর রূপ হলো event time বনাম processing time, যা streaming সিস্টেমের মূল প্রশ্ন; Akidau ও সহকর্মীদের ২০১৫ সালের “Dataflow Model” প্রবন্ধ এর পরিচিত সূত্র।

পরীক্ষা ও ইন্টারভিউতে:

  • RANK, DENSE_RANK আর ROW_NUMBER, মান 900, 700, 700, 500? RANK: ১, ২, ২, ৪; DENSE_RANK: ১, ২, ২, ৩; ROW_NUMBER: ১, ২, ৩, ৪ (টাইয়ে ক্রম অনির্দিষ্ট)।
  • প্রতিটি গ্রাহকের সর্বশেষ অর্ডার? ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_ts DESC), তারপর বাইরের কোয়েরিতে rn = 1; WHERE-এ সরাসরি window function লেখা যায় না।
  • Step conversion ৫০%, ৪০%, ৮০% হলে overall? ০.৫ × ০.৪ × ০.৮ = ১৬%.
  • Join-এর পর revenue দ্বিগুণ কেন? এক → অনেক join-এ fan-out; আগে গুটিয়ে join, আর join-এর আগে-পরে সারি গুনে দেখা।
  • Cohort টেবিলের ফাঁকা ঘর ০ না NULL? মাসটি দেখা না হলে NULL (censored); দেখা মাসে কেউ না কিনলে ০.

১০ · AI-এর যুগে — AI-এর লেখা analytics SQL যাচাই

“জুনের funnel আর cohort-এর SQL লিখে দাও” বললে AI assistant কয়েক সেকেন্ডে সুন্দর কোয়েরি দেয়, যা চলে এবং ভুল সংখ্যা দেয়। সাধারণ ভুলগুলো:

AI প্রায়ই যা করেকেন ভুলকীভাবে ধরবেন
অর্ডার আর লাইন join করে order-স্তরের কলাম SUMFan-out, সংখ্যা ফুলে ওঠেjoin-এর আগে-পরে COUNT আর COUNT(DISTINCT key)
অন্য dialect-এর ফাংশন: SQLite-এ PERCENTILE_CONT, DATEDIFF, DATE_TRUNCভুল ডাটাবেসের ব্যাকরণ; কখনো চুপচাপ ভুল (SQLite-এ তারিখের লেখা বিয়োগ)নিজের ডাটাবেস আর version বলে দিন, ছোট উদাহরণে চালান
UTC-র date() দিয়ে দৈনিক বা মাসিক গোনাস্থানীয় দিনের সীমানা সরে যায়রূপান্তর কোথায় হলো খুঁজুন; মাঝরাতের অর্ডার হাতে মিলিয়ে দেখুন
Cohort-এর না-দেখা ঘরে ০, বা হর হিসেবে শুধু ক্রেতাRetention ভুলভাবে কম বা বেশিএক সারি হাতে বানিয়ে মেলান
খালি দিন না ভরে ROWS 6 PRECEDING৭ সারি ≠ ৭ দিনক্যালেন্ডার টেবিল; days_used ধরনের কলাম
Feature-এ cutoff-এর পরের ডেটাLeakage, model অবাস্তব ভালোপ্রতিটি feature কোয়েরিতে cutoff শর্ত খুঁজুন

প্রম্পটে টেবিলের কাঠামো আর কয়েকটি বানানো নমুনা সারি দিন, আসল গ্রাহকের ডেটা নয়; আর উত্তরের সঙ্গে যাচাইয়ের কোয়েরি চেয়ে নিন।

আমি SQLite 3.49 ব্যবহার করি। টেবিল: customers(customer_id, signup_date 'YYYY-MM-DD', ঢাকার তারিখ), orders(order_id, customer_id, order_ts UTC 'YYYY-MM-DD HH:MM:SS', status, delivery_fee), order_items(order_id, product_id, qty, unit_price). মাসিক signup cohort-এর retention টেবিল চাই (M0 থেকে M4). ১. কেনার মাস ঢাকার সময়ে (UTC+6) ধরো, আর কোথায় রূপান্তর করলে লাইন ধরে দেখাও। ২. হর হবে cohort-এর সব গ্রাহক; যে মাস ডেটায় এখনও আসেনি, সেখানে NULL দাও, 0 নয়। ৩. SQLite-এ নেই এমন কোনো ফাংশন ব্যবহার করবে না; ব্যবহার করলে স্পষ্ট বলো। ৪. Fan-out হতে পারে এমন কোনো join থাকলে কারণসহ জানাও। ৫. শেষে একটি ছোট যাচাই-কোয়েরি দাও, যা একটি cohort-এর একটি ঘর হাতে গোনার মতো করে মেলায়।

AI-এর দেওয়া analytics SQL হাতে পেলে — যাচাইয়ের চেকলিস্ট

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।

প্র ০১Window function আসার আগে analyst-রা running total বা “আগের অর্ডার” কীভাবে বের করতেন, আর এই সুবিধা এত দেরিতে সব ডাটাবেসে এল কেন?

Window function-এর আগে দুটি প্রচলিত পথ ছিল। একটি self-join: অর্ডার টেবিলকে নিজের সঙ্গে join করে শর্ত দেওয়া “একই গ্রাহক, আর তারিখ এর চেয়ে ছোট”, তারপর GROUP BY করে যোগফল বা MAX তারিখ। অন্যটি correlated subquery: প্রতিটি সারির জন্য একটি ছোট কোয়েরি, “এই গ্রাহকের এর আগের অর্ডারের সর্বোচ্চ তারিখ কত”। দুটিই ঠিক উত্তর দেয়, কিন্তু একজনের n টি অর্ডার থাকলে প্রায় n² জোড়া তৈরি হয়, তাই বড় টেবিলে খুব ধীর; আর কোয়েরি পড়া কঠিন, ভুলের জায়গা বেশি। অনেকে তাই ডেটা বের করে spreadsheet বা প্রোগ্রামে হিসাব করতেন।

Window function-এর ধারণা SQL মানে আসে OLAP (অনলাইন বিশ্লেষণ) কাজের চাপে, SQL:1999-এর একটি সংশোধনী হয়ে SQL:2003-এ। বাণিজ্যিক কিছু ডাটাবেস আগে থেকেই নিজেদের রূপে দিত, কিন্তু খোলা উৎসের ডাটাবেসে এসেছে অনেক পরে: PostgreSQL ৮.৪ (২০০৯), MySQL ৮.০ আর SQLite ৩.২৫ (দুটিই ২০১৮)। কারণ বাস্তবায়ন সহজ নয়: partition আর ক্রম অনুযায়ী সাজানো, frame-এর ধরন (ROWS, RANGE, GROUPS), টাই, আর optimizer-কে শেখানো কখন একটি sort-এ অনেক ফাংশন চলবে।

এই দেরির একটি ফল আজও দেখা যায়: পুরোনো টিউটোরিয়াল, Stack Overflow উত্তর আর তাদের থেকে শেখা AI assistant কখনো কখনো self-join বা session ভেরিয়েবলের কৌশল দেয়, যা এখন অপ্রয়োজনীয় এবং কোনো কোনো ক্ষেত্রে অনির্ভরযোগ্য। Data scientist-এর জন্য বড় লাভ হলো, হিসাব ডেটার কাছেই হয়: কোটি সারি ল্যাপটপে না এনে ডাটাবেসেই running total, LAG আর ক্রম বানানো যায়, আর ফল ছোট হয়ে আসে।

মূল উপলব্ধি: Window function “প্রতিটি সারি রাখো, পাশে দলের হিসাব বসাও” ধারণাটিকে একটি sort-এর দামে নিয়ে এসেছে; self-join আর subquery-র পুরোনো কৌশল চিনে রাখুন, কিন্তু নতুন কোয়েরিতে window function-ই প্রথম পছন্দ।

প্র ০২মোট সক্রিয় গ্রাহক বাড়ছে, অথচ cohort টেবিল খারাপ দেখাচ্ছে। এটি কীভাবে সম্ভব, আর cohort-এর ধারণা কোথা থেকে এসেছে?

“Cohort” শব্দটি এসেছে রোমান সেনাবাহিনীর একটি দলের নাম থেকে, আর পরিসংখ্যানে এসেছে জনসংখ্যা-বিজ্ঞান ও মহামারি-বিজ্ঞান থেকে: একই সময়ে জন্ম নেওয়া বা একই সময়ে কোনো কিছুর সংস্পর্শে আসা মানুষের দলকে বছরের পর বছর অনুসরণ করা। মূল যুক্তি একটাই: আলাদা সময়ে শুরু করা মানুষদের এক থলিতে মেশালে সময়ের প্রভাব আর দলের পার্থক্য আলাদা করা যায় না।

ব্যবসায় এর সবচেয়ে পরিচিত রূপ এই প্রশ্নের উত্তর। ধরুন বাজারঘর প্রতি মাসে বিজ্ঞাপনে আগের চেয়ে বেশি খরচ করছে, তাই নতুন গ্রাহক দ্রুত বাড়ছে। মোট সক্রিয় গ্রাহকের সংখ্যা তখন বাড়বে, এমনকি যদি প্রতিটি নতুন দলের মাত্র ২০% পরের মাসে ফেরেন এবং সেই হার মাসে মাসে কমতে থাকে। নতুনের স্রোত পুরোনোদের চলে যাওয়া ঢেকে দেয়। Cohort টেবিল কলাম ধরে পড়লে (একই M1-এ মার্চ ৫০%, এপ্রিল ২০%) এই অবনতি সঙ্গে সঙ্গে চোখে পড়ে। বিজ্ঞাপন বন্ধ হলেই মোট সংখ্যা হঠাৎ পড়ে যায়, আর তখন অনেক দেরি।

তবে cohort টেবিলেরও সীমা আছে। ছোট দলে (এখানে ৩০ জন) একজন মানে ৩.৩ শতাংশ বিন্দু, তাই এলোমেলো ওঠানামা বড় দেখায়; শতাংশের পাশে n লিখুন, আর দরকারে পর্ব ২১-এর bootstrap দিয়ে পরিসর দিন। ক্যালেন্ডার মাসের সীমানা প্রভাব ফেলে (ফেব্রুয়ারির M0 দেখুন)। আর দল তৈরির মাপকাঠি বদলালে (প্রথম কেনার মাস, প্রথম কুপনের ধরন, কোন বিজ্ঞাপন থেকে এসেছেন) সম্পূর্ণ ভিন্ন গল্প বেরোতে পারে। Cohort তুলনা পর্যবেক্ষণমূলক; মার্চের দল ভালো কেন, তা টেবিল বলে না।

মূল উপলব্ধি: মোট সংখ্যা বৃদ্ধি আর ধরে রাখার ক্ষমতা মিশিয়ে ফেলে; cohort টেবিল দুটিকে আলাদা করে, কিন্তু তার পাশে দলের আকার, censoring আর সংজ্ঞাও লিখতে হয়।

প্র ০৩দুটি দল একই ডেটা থেকে funnel বানিয়ে আলাদা conversion পেল। কোন কোন সিদ্ধান্তে funnel-এর সংখ্যা বদলে যায়?

Funnel দেখতে সরল, কিন্তু অন্তত পাঁচটি সিদ্ধান্ত তার সংখ্যা বদলায়। প্রথম, একক: গ্রাহক না session. এই ডেটায় গ্রাহক-স্তরে view থেকে paid ৩১.৯%, session-স্তরে ৩৩.৩%; আর ধাপের মাঝের সময় গ্রাহক-স্তরে প্রায় এক দিন, session-স্তরে মিনিটের ঘরে। দ্বিতীয়, ক্রম মানা হবে কি না: শুধু “কোনো সময় cart করেছেন” গুনলে এমন কেউও ঢুকে পড়েন যিনি পুরোনো cart থেকে সরাসরি কিনেছেন। তৃতীয়, সময়ের জানালা: view-এর পর ৩০ মিনিট, এক দিন, না পুরো মাসের মধ্যে paid হলে গোনা হবে? জানালা যত বড়, conversion তত বেশি।

চতুর্থ, ডেটা পরিষ্কার: দুবার পাঠানো ঘটনা, bot বা নিজেদের পরীক্ষার অ্যাকাউন্ট, লগইন না করা দর্শক (যাঁদের customer_id নেই, তাই এই funnel-এ তাঁরা অদৃশ্য)। পঞ্চম, হর: প্রথম ধাপ কি “যে কোনো পাতা দেখেছেন” না “পণ্যের পাতা দেখেছেন”? বাণিজ্যিক analytics টুলগুলোও এসব বিষয়ে নিজেদের default ঠিক করে, আর সেগুলো টুলভেদে আলাদা হতে পারে; তাই দুটি টুলের funnel সরাসরি তুলনা করার আগে তাদের সংজ্ঞা মিলিয়ে নিতে হয়।

আরেকটি গভীর সমস্যা হলো funnel পর্যবেক্ষণমূলক। Cart থেকে checkout-এ বড় ক্ষতি দেখে “checkout পাতা খারাপ” সিদ্ধান্ত টানা লোভনীয়, কিন্তু হয়তো delivery fee সেখানে প্রথম দেখানো হয়, বা যাঁরা শুধু দাম মিলিয়ে দেখছিলেন তাঁরাই ঝরে পড়ছেন। কোন পরিবর্তন সত্যিই conversion বাড়ায়, তা জানতে পরীক্ষা লাগে: পর্ব ৬২ আর পর্ব ৬৩ (A/B test-এর বিশ্লেষণ — t-test, chi-square, CUPED আর ভুল ব্যাখ্যা)। আর পর্যবেক্ষণের ডেটা থেকে কারণ টানার সীমা পর্ব ৬৪ (Causal inference-এর পরিচয় — DAG, matching আর difference-in-differences)-এ।

মূল উপলব্ধি: Funnel-এর প্রতিটি সংখ্যার পেছনে একক, ক্রম, সময়ের জানালা, পরিষ্কারের নিয়ম আর হরের সিদ্ধান্ত থাকে; সেগুলো লিখে না রাখলে দুটি funnel তুলনা অর্থহীন, আর funnel একাই কারণ বলে না।

প্র ০৪“সব সময় UTC-তে রাখুন” নিয়মটি কেন এত প্রচলিত, আর শুধু এটুকু মানলেই কি সময়ের সব সমস্যা মেটে?

UTC-তে রাখার যুক্তি সরল: একটি মুহূর্তের একটিই পরিচয় থাকে। সার্ভার সিঙ্গাপুরে, ব্যবহারকারী ঢাকায়, বিশ্লেষক লন্ডনে থাকলেও ঘটনার ক্রম আর ব্যবধান ঠিক থাকে। স্থানীয় সময়ে রাখলে daylight saving-এর দেশে বছরে একবার এক ঘণ্টা দুবার আসে আর একবার একটি ঘণ্টা হারিয়ে যায়, ফলে ক্রম গুলিয়ে যায়। বাংলাদেশে এখন daylight saving নেই, তবে ২০০৯ সালে অল্প কয়েক মাস চালু ছিল বলে জানা যায়; সেই সময়ের ডেটায় “+৬ ঘণ্টা” ভুল, তাই IANA-র সময়-অঞ্চলের তথ্যভান্ডারের Asia/Dhaka নাম দিয়ে রূপান্তর নিরাপদ। লেখার জন্য ISO 8601 ধাঁচ (2026-06-01 14:05:10) সুবিধাজনক, কারণ লেখা হিসেবে সাজালেও সময়ের ক্রম ঠিক থাকে।

কিন্তু UTC-তে রাখা অর্ধেক কাজ। ব্যবসার “দিন” স্থানীয়, তাই প্রতিটি দৈনিক, সাপ্তাহিক বা মাসিক হিসাবে রূপান্তর লাগে, আর ফিল্টারের সীমাও রূপান্তর করতে হয়; এই পর্বের জুনের অর্ডার গোনায় সীমা ছিল UTC-র ৩১ মে ১৮:০০. দ্বিতীয় সমস্যা হলো কোন সময়: ঘটনা ঘটার সময় (event time) না সার্ভারে পৌঁছানোর সময় (processing time). দুর্বল নেটওয়ার্কে ফোন ঘটনা জমিয়ে রেখে পরে পাঠায়; রাত ১১:৫৯-এর কেনা পরদিন সকালে পৌঁছালে processing time ধরে গুনলে তা ভুল দিনে পড়ে। Streaming সিস্টেমে এই পার্থক্য নিয়ে Akidau ও সহকর্মীদের ২০১৫ সালের Dataflow Model প্রবন্ধ বহুল আলোচিত। তৃতীয়ত, ফোনের ঘড়ি ভুল হতে পারে, তাই device-এর দেওয়া সময় আর সার্ভারের সময় দুটিই রাখা ভালো।

আর শেষ কথা, কোন সময়-অঞ্চলে রিপোর্ট হবে, তা ব্যবসার সিদ্ধান্ত: দেশের বাইরের গ্রাহক থাকলে “দিন” কার ঘড়িতে? এটি এক জায়গায় লিখে সব রিপোর্টে একই রাখা দরকার।

মূল উপলব্ধি: UTC-তে রাখা মুহূর্তকে নিরাপদ রাখে, কিন্তু দিনের সীমানা, ফিল্টার, event বনাম processing time আর রিপোর্টের সময়-অঞ্চল আলাদা করে ঠিক করতে হয়।

প্র ০৫হিসাব SQL-এ করবেন না pandas-এ? “ডেটার কাছে হিসাব নিয়ে যান” নীতির পক্ষে-বিপক্ষে কী আছে?

“ডেটার কাছে হিসাব” নীতির যুক্তি হলো ডেটা সরানো ব্যয়বহুল। কোটি সারির টেবিল ল্যাপটপে নামাতে সময়, নেটওয়ার্ক আর মেমরি লাগে, আর ব্যক্তিগত তথ্য অপ্রয়োজনে ছড়ায়। ডাটাবেস বা warehouse-এ GROUP BY আর window function চালালে ফল ছোট হয়ে আসে, আর ডাটাবেস index, সমান্তরাল প্রক্রিয়া ও optimizer কাজে লাগায়। SQL ঘোষণামূলক: আপনি কী চান বলেন, কীভাবে নয়; তাই একই কোয়েরি ডেটা বাড়লেও প্রায়ই চলে। আর একটি কোয়েরি সংজ্ঞা হিসেবে ভাগ করা যায়: “সক্রিয় গ্রাহক” কী, তা একটি view-তে লিখলে সব রিপোর্ট একই সংজ্ঞা পায়। অনেক দল তাই বিশ্লেষণের মূল টেবিল SQL-এ version-নিয়ন্ত্রিত মডেল হিসেবে রাখে (dbt-এর মতো টুল এই ধারার পরিচিত উদাহরণ)।

অন্যদিকে pandas (বা Polars) জটিল ধাপের জন্য সহজ: বারবার পরীক্ষা, চিত্র আঁকা, model শেখানো, অদ্ভুত parsing, আর লুপের মতো যুক্তি যা SQL-এ অস্বস্তিকর। Unit test লেখা আর debugger চালানোও সহজ। বাস্তবে বেশির ভাগ কাজ মিশ্র: SQL-এ ছেঁকে, join করে, গুটিয়ে, তারপর ছোট ফল pandas-এ; এই পর্বের feature-এর ব্লক ঠিক তাই। মাঝামাঝি একটি পথও জনপ্রিয় হয়েছে: DuckDB-এর মতো in-process analytical ডাটাবেস, যা ল্যাপটপেই Parquet ফাইল বা DataFrame-এর ওপর SQL চালায়; পর্ব ৭০ (বড় ডেটায় ML — out-of-core, incremental learning আর Polars/DuckDB)-এ আসবে।

কোন পথ বেছে নিন না কেন, দুটি ঝুঁকি থাকে। Dialect-এর পার্থক্য (median, তারিখের ফাংশন, integer ভাগ) কোয়েরিকে অন্য ডাটাবেসে নিয়ে গেলে চুপচাপ ভুল করাতে পারে। আর একই সংখ্যা দুই জায়গায় দুইভাবে হিসাব হলে সময়ের সঙ্গে দুটি আলাদা হয়ে যায়। তাই এক সংজ্ঞা এক জায়গায় রাখুন, আর গুরুত্বপূর্ণ সংখ্যা মাঝে মাঝে দ্বিতীয় পথে মিলিয়ে দেখুন (AI-এর ভিত্তি কোর্সের পর্ব ১২-এর পুনরুৎপাদনযোগ্যতার নিয়মে)।

মূল উপলব্ধি: ভারী ছাঁকা, join আর গোটানো SQL-এ, পরীক্ষা আর model pandas-এ; সংজ্ঞা থাকুক এক জায়গায়, আর গুরুত্বপূর্ণ সংখ্যা দুই পথে মিলুক।

অনুশীলন

হিসাবগুলো খাতায় করুন, তারপর উত্তর মেলান। একটির জন্য SQL লেখা লাগবে; পাতার যেকোনো এডিটরে চালাতে পারেন।

  1. হাতের হিসাব: (ক) একটি funnel-এ ২,০০০ জন view, ৭০০ জন cart, ৪২০ জন checkout, ৩৫৭ জন paid. প্রতিটি step conversion আর overall conversion বের করুন। (খ) মে মাসের cohort-এ ৪০ জন; জুনে কিনেছেন ১২ জন, জুলাইয়ে ৯ জন; ডেটা ১৫ জুলাই পর্যন্ত। M1 আর M2 ঘরে কী লিখবেন? (গ) সাজানো ৯টি মান: 2, 3, 3, 5, 8, 9, 12, 20, 41. PERCENTILE_CONT নিয়মে median আর p90 কত? গড় কত?

    (ক) cart ÷ view = ৭০০ ÷ ২,০০০ = ৩৫%; checkout ÷ cart = ৪২০ ÷ ৭০০ = ৬০%; paid ÷ checkout = ৩৫৭ ÷ ৪২০ = ৮৫%. Overall = ৩৫৭ ÷ ২,০০০ = ১৭.৮৫%, যা ০.৩৫ × ০.৬০ × ০.৮৫ = ০.১৭৮৫-এর সমান।

    (খ) M1 = ১২ ÷ ৪০ = ৩০.০%. M2 জুলাই, যা অর্ধেক দেখা হয়েছে। ৯ ÷ ৪০ = ২২.৫% লিখলে ঘরটি কম দেখাবে, কারণ বাকি অর্ধেক মাসে আরও কেউ কিনতে পারেন। সৎ পথ: মাস শেষ না হওয়া পর্যন্ত NULL (বা “অসম্পূর্ণ” চিহ্নসহ ২২.৫%).

    (গ) n = ৯. Median: pos = ১ + ৮ × ০.৫ = ৫, পঞ্চম মান = ৮. p90: pos = ১ + ৮ × ০.৯ = ৮.২; অষ্টম মান ২০, নবম ৪১; ২০ + ০.২ × (৪১ − ২০) = ২৪.২. গড় = ১০৩ ÷ ৯ ≈ ১১.৪৪: একটি বড় মান (৪১) গড়কে median-এর চেয়ে অনেক ওপরে টেনেছে।

  2. সমালোচনা ও নকশা: একজন analyst-এর রিপোর্ট: “জুনের মোট আয় orders JOIN order_items করে SUM(o.delivery_fee) + SUM(i.qty * i.unit_price). দৈনিক অর্ডার date(order_ts) দিয়ে গোনা। মাসিক সক্রিয় গ্রাহক = ৩০ দিনের দৈনিক সক্রিয় গ্রাহকের যোগফল। Cohort টেবিলে ফাঁকা ঘর ০ দিয়ে ভরানো, যাতে chart সুন্দর দেখায়। গড় কুপন-ছাড় = AVG(discount).” কী কী সমস্যা, আর কীভাবে ঠিক করবেন?

    সমস্যা: (১) Fan-out: delivery fee প্রতিটি লাইনে একবার করে যোগ হয়েছে; এই ডেটায় তা আসলের প্রায় ১.৬ গুণ। (২) date(order_ts) UTC-র তারিখ; ঢাকার মাঝরাত থেকে ভোর ছয়টার অর্ডার আগের দিনে পড়েছে, আর মাসের সীমায় এক মাসের অর্ডার অন্য মাসে গেছে। (৩) Distinct গোনা যোগ করা যায় না; একজন দশ দিন এলে দশবার গোনা হয়েছে। (৪) না-দেখা মাসে ০ মানে “সবাই চলে গেছেন”, যা মিথ্যা। (৫) AVG(discount) শুধু কুপন ব্যবহৃত অর্ডারের গড়; প্রশ্নটি “অর্ডারপ্রতি” হলে ভুল।

    ঠিক করা: লাইন আগে অর্ডার-স্তরে গুটিয়ে join; date(order_ts, '+6 hours') (দীর্ঘ ইতিহাসে Asia/Dhaka-র নিয়মে রূপান্তর); মাসিক সক্রিয় = মাসের ওপর COUNT(DISTINCT customer_id); না-দেখা ঘর NULL আর chart-এ ফাঁকা; প্রশ্ন অনুযায়ী AVG(COALESCE(discount, 0)), আর দুটি সংখ্যাই নাম দিয়ে রিপোর্ট। শেষে একটি দিনের আয় হাতে গুনে মিলিয়ে নেওয়া।

  3. আউটপুট অনুমান করুন: চালানোর আগে ফলের টেবিলটি লিখে ফেলুন, তারপর যেকোনো এডিটরে চালিয়ে মিলিয়ে দেখুন।
    WITH t(cust, d, amt) AS (VALUES
      (1, '2026-06-01', 300), (1, '2026-06-04', 200),
      (2, '2026-06-02', 500), (1, '2026-06-10', 300),
      (2, '2026-06-03', 500))
    SELECT cust, d, amt,
           SUM(amt) OVER (PARTITION BY cust ORDER BY d)          AS run,
           julianday(d) - julianday(LAG(d) OVER (PARTITION BY cust
                                                 ORDER BY d)) AS gap,
           RANK() OVER (PARTITION BY cust ORDER BY amt DESC)     AS rk
    FROM t ORDER BY cust, d;
    cust | d          | amt | run  | gap  | rk
    1    | 2026-06-01 | 300 | 300  | NULL | 1
    1    | 2026-06-04 | 200 | 500  | 3    | 3
    1    | 2026-06-10 | 300 | 800  | 6    | 1
    2    | 2026-06-02 | 500 | 500  | NULL | 1
    2    | 2026-06-03 | 500 | 1000 | 1    | 1

    প্রতিটি গ্রাহকের partition আলাদা, তাই run আর LAG গ্রাহক ২-তে নতুন করে শুরু। গ্রাহক ১-এর দুটি ৩০০ টাকার অর্ডার RANK-এ দুটিই ১, তাই ২০০ টাকার অর্ডার পায় ৩ (RANK-এর ফাঁক)। গ্রাহক ২-এর দুটি অর্ডারই ৫০০, দুটিই ১. gap দিনের সংখ্যা, julianday-এর পার্থক্য; এখানে সময় নেই বলে পূর্ণসংখ্যা (Python-এর sqlite3 একই মান 3.0 লিখে দেখায়)।

  4. কোড লিখুন: অংশ ৫-এর funnel ছিল গ্রাহক-স্তরের। অংশ ৪-এর session-এর ধারণা ব্যবহার করে session-স্তরের funnel লিখুন: কতগুলো session-এ view, cart, checkout আর paid ঘটেছে, আর ধাপভিত্তিক হার। তারপর গ্রাহক-স্তরের ফলের সঙ্গে তুলনা করুন। (সূত্র: session-এর SQL view দুটি নিয়ে এডিটরে শুরু করুন।)

    অংশ ৪-এর দ্বিতীয় এডিটরের e আর s view দুটি রেখে নিচের কোয়েরি যোগ করুন:

    WITH ss AS (SELECT customer_id, session_no,
                       MAX(event_type = 'cart')     AS c,
                       MAX(event_type = 'checkout') AS k,
                       MAX(event_type = 'paid')     AS p
                FROM s GROUP BY customer_id, session_no)
    SELECT COUNT(*) AS sessions, SUM(c) AS cart, SUM(k) AS checkout, SUM(p) AS paid,
           ROUND(100.0 * SUM(c) / COUNT(*), 1) AS cart_pct,
           ROUND(100.0 * SUM(k) / SUM(c), 1)   AS check_pct,
           ROUND(100.0 * SUM(p) / SUM(k), 1)   AS paid_pct
    FROM ss;

    ফল: 129 | 76 | 52 | 43 | 58.9 | 68.4 | 82.7. প্রতিটি session view দিয়ে শুরু, তাই view-এর সংখ্যা ১২৯. Session-স্তরে overall ৪৩ ÷ ১২৯ = ৩৩.৩%, গ্রাহক-স্তরে ছিল ৩১.৯%; আর ধাপের হার আলাদা, কারণ এখন একজন গ্রাহকের প্রতিটি ভিজিট আলাদা সুযোগ। টাকা দেওয়া session-এ প্রথম cart থেকে paid গড়ে ১৩.৭ মিনিট, গ্রাহক-স্তরের প্রায় এক দিনের তুলনায়। এখানে ক্রম ধরে নেওয়া হয়েছে, কারণ একটি session-এ ধাপগুলো ক্রমেই ঘটে; সাধারণ ডেটায় session-এর ভেতরেও সময়ের শর্ত দিতে হবে।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমSQL-এ মাসিক cohort retention টেবিল বানানোর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. Cohort আর “সক্রিয়” কাকে বলে লিখে ঠিক করা, স্থানীয় সময়-অঞ্চলসহ
  2. বাতিল অর্ডার আর duplicate বাদ দিয়ে কেনার সময় স্থানীয় সময়ে রূপান্তর
  3. প্রতিটি (গ্রাহক, মাসের ব্যবধান m) জোড়া একবার করে বের করা
  4. প্রতিটি ঘরে সক্রিয় গ্রাহক গুনে cohort-এর মোট আকার দিয়ে ভাগ
  5. না-দেখা মাসের ঘর NULL রেখে একটি ঘর হাতে গুনে মিলিয়ে দেখা

একটি partition-এ মানগুলো বড় থেকে ছোট ক্রমে 900, 700, 700, 500. RANK() OVER (ORDER BY v DESC) দিলে 500-এর ক্রম কত?

  • 2
  • 3
  • 4
  • 5

দৈনিক অর্ডার GROUP BY date করে বানানো, আর কিছু দিনে কোনো অর্ডার নেই। তার ওপর AVG(n) OVER (ORDER BY d ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) চালালে কী সমস্যা?

  • কোয়েরিটি error দেবে, কারণ কিছু তারিখ নেই
  • জানালা ৭ দিনের বেশি ছড়িয়ে পড়ে আর শূন্যের দিন বাদ পড়ায় গড় ফুলে ওঠে
  • খালি দিনগুলো স্বয়ংক্রিয়ভাবে 0 ধরা হয়, তাই কোনো সমস্যা নেই
  • শুধু প্রথম ছয়টি সারিতে NULL আসবে

একটি অর্ডারের delivery fee 100 টাকা, আর order_items-এ তার 3টি লাইন। orders JOIN order_items করে SUM(delivery_fee) নিলে এই অর্ডারের অংশ কত আসবে?

  • 300
  • 100
  • 33.3
  • NULL

চারটি অর্ডারের discount কলাম: 100, NULL, NULL, 50. AVG(discount) আর AVG(COALESCE(discount, 0)) কত?

  • 37.5 আর 37.5
  • NULL আর 37.5
  • 37.5 আর 75
  • 75 আর 37.5

একটি funnel-এর তিনটি step conversion 50%, 40% আর 80%. প্রথম ধাপের 1,000 জনের মধ্যে কতজন শেষ ধাপে পৌঁছান?

  • প্রায় 567
  • 160
  • 400
  • 800

ডেটা 30 জুন পর্যন্ত। এপ্রিলে যোগ দেওয়া cohort-এর M3 (জুলাই) ঘরে retention টেবিলে কী লেখা উচিত?

  • 0, কারণ কেউ কেনার রেকর্ড নেই
  • M2-এর মানটিই, কারণ retention সাধারণত ধীরে বদলায়
  • NULL বা ফাঁকা, কারণ মাসটি এখনও দেখা হয়নি
  • মোট ক্রেতা দিয়ে ভাগ করা একটি আনুমানিক হার

একটি event টেবিলে কিছু ঘটনা দুবার এসেছে, প্রতিবার আলাদা event_id-সহ। প্রতিটি ঘটনার একটি সারি রাখার নির্ভরযোগ্য উপায় কোনটি?

  • ROW_NUMBER() OVER (PARTITION BY customer_id, event_type, event_ts ORDER BY event_id), তারপর rn = 1
  • SELECT DISTINCT * FROM events
  • GROUP BY event_id
  • LIMIT 1

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. Window function সারি রাখে: PARTITION BY দল, ORDER BY ক্রম, frame জানালা। Running total, RANK, NTILE, LAG আর LEAD দিয়ে গ্রাহকের ইতিহাস এক কোয়েরিতে।
  2. সারি ≠ দিন: moving average-এর আগে ক্যালেন্ডার ভরুন; সময় UTC-তে রাখুন আর দিনের সীমানা ও ফিল্টার ঢাকার সময়ে রূপান্তর করুন।
  3. আগে পরিষ্কার: ROW_NUMBER দিয়ে duplicate বাদ; LAG, ৩০ মিনিটের নিয়ম আর running total দিয়ে session; event-এর গোনা লেনদেনের টেবিলের সঙ্গে মেলান।
  4. Funnel আর cohort-এর সংজ্ঞা লিখুন: একক, ক্রম, সময়ের জানালা আর হর; cohort-এর হর সব গ্রাহক, আর না-দেখা মাস NULL, শূন্য নয়।
  5. নীরব ফাঁদ: fan-out-এর আগে গুটিয়ে join, AVG-এর NULL, যোগ না করা যায় এমন COUNT(DISTINCT); SQLite-এ PERCENTILE_CONT নেই, তাই pos = ১ + (n − ১)p; গুরুত্বপূর্ণ সংখ্যা pandas-এ মিলিয়ে নিন।

পরের পর্বে: পর্ব ৬২ (A/B test-এর পরিকল্পনা — power, sample size আর peeking-এর ফাঁদ)। Funnel দেখিয়েছে কোথায় ক্রেতা ঝরে পড়েন; এবার পরিবর্তন করে মাপার পরীক্ষা কীভাবে সাজাবেন, কত ব্যবহারকারী লাগবে আর আগেভাগে ফল দেখার বিপদ কী।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান