অধ্যায় 09

নতুন কলাম যোগ করা — ফাইলে যে সংখ্যাগুলো নেই তা তৈরি করা

বিদ্যমান কলাম থেকে নতুন কলাম তৈরি: পুরো কলামের ওপর পাটিগণিত এবং ইনডেক্স কীভাবে প্রতিটি মান বসায়, assign, np.where ও np.select, map, pd.cut, .str ও .dt, প্রতিটি সারির সাথে গ্রুপের তুলনা করার জন্য transform, এবং শেষ উপায় হিসেবে apply।

50 মিনিটPython 3.12
  1. 1সমস্যা
  2. 2বোঝা
  3. 3উদাহরণ
  4. 4অনুমান
  5. 5নিজে করা
  6. 6কঠিন করা

যে সমস্যাটা আমরা সমাধান করছি

দোকানের মালিক একটি বার্তা পাঠালেন: "প্রতিটি অর্ডারের ক্ষেত্রে আমি দেখতে চাই তা থেকে কত টাকা আয় হয়েছে, অর্ডারটি বড় ছিল কি না, কোন ম্যানেজার এটির দায়িত্বে আছেন, এবং সেটি সপ্তাহের কী বার ছিল। আর প্রতিটি অর্ডারের ক্ষেত্রে, তা তার নিজস্ব ব্রাঞ্চের মোট আয়ের কত শতাংশ?"

আপনি orders.csv ফাইলটি খুললেন। সেখানে রয়েছে quantity (পরিমাণ) এবং price (দাম), branch (তিনটি দোকানের কোনটি) এবং date (তারিখ)। তিনি যা যা চেয়েছেন তার একটি তথ্যও ফাইলে সরাসরি নেই। প্রতিটি তথ্যই ফাইলে আগে থেকে থাকা কলামগুলো ব্যবহার করে তৈরি করে নিতে হবে।

আপনি যদি সাধারণ পাইথন থেকে এসে থাকেন, তবে আপনার হাত ইতিমধ্যেই জানে কী টাইপ করতে হবে — সারিগুলোর ওপর একটি লুপ চালানো:

python
import pandas as pd

orders = pd.read_csv("orders.csv")

revenues = []
for i in range(len(orders)):
    revenues.append(orders.loc[i, "quantity"] * orders.loc[i, "price"])
orders["revenue"] = revenues

print(orders[["product", "quantity", "price", "revenue"]])
text
product  quantity  price  revenue
0       pen        12   15.0    180.0
1  notebook         5   60.0    300.0
2       bag         2  850.0   1700.0
3    bottle         7  120.0    840.0
4    eraser        30    8.0    240.0
5       bag         1  850.0    850.0
6       pen        20   15.0    300.0
7    bottle         4  120.0    480.0

এটি কাজ করে। তবে এই অভ্যাসটি দূর করার জন্যই এই অধ্যায়, যার পেছনে তিনটি কারণ রয়েছে যা সাধারণত পরে ধরা পড়ে।

প্রথমত, এটি ধীরগতির: পাইথন প্রতি সারির জন্য লুপের ভেতরের অংশটি একবার করে চালায়, তাই দশ লাখ সারির ফাইলে ইন্টারপ্রেটারকে দশ লাখ বার ঘুরতে হয়। দ্বিতীয়ত, এটি অযথা দীর্ঘ: একটিমাত্র গুণের জন্য চার লাইন কোড, আর মালিকের তালিকায় আরও পাঁচটি কলাম রয়েছে। আর তৃতীয়ত, এটি এমনভাবে ঝুঁকিপূর্ণ যা সহজে চোখে পড়ে না। লুপটি পজিশন বা অবস্থান হিসেবে 0, 1, 2, … গণনা করে এবং তারপর .loc দিয়ে সেগুলোকে লেবেল হিসেবে খোঁজে। এটি কেবল তখনই কাজ করে যখন পজিশন আর লেবেল কাকতালীয়ভাবে একই থাকে। কিন্তু অধ্যায় পাঁচের মতো টেবিলটিকে আগে ফিল্টার করে নিন — ধরুন কেবল উত্তর (north) ব্রাঞ্চের অর্ডারগুলো আলাদা করলেন — তখন আর তারা এক থাকে না:

python
north = orders[orders["branch"] == "north"]
print(north.index.tolist())

revenues = []
for i in range(len(north)):
    revenues.append(north.loc[i, "quantity"] * north.loc[i, "price"])
text
[0, 2, 4, 7]
KeyError: 1

north-এর সারিগুলো তাদের মূল লেবেল 0, 2, 4, 7 ধরে রাখে। কিন্তু লুপটি লেবেল 1 খোঁজে, যা দক্ষিণ (south) ব্রাঞ্চের অর্ডার এবং north-এর মধ্যে নেই, ফলে কোড ক্র্যাশ করে। এই ভুলের আরও খারাপ রূপ হলো যখন কোড ক্র্যাশও করে না — বরং নীরবে ভুল সারির তথ্য পড়ে হিসাব চালিয়ে যায়।

পান্ডাসে এই বিষয়টি ভাবার ধরন সম্পূর্ণ আলাদা: আপনি একবারে একটি সারি ধরে কলামের হিসাব করেন না; পুরো কলামের জন্য একবারে এক লাইনে হিসাব করেন। এই একটি ধারণাই এই অধ্যায়ের মূল কথা, আর এটি একবার মাথায় ঢুকে গেলে দোকানের মালিকের চাওয়া প্রতিটি কলাম তৈরি করা মাত্র এক লাইনের কাজ হয়ে যায়।

এই অধ্যায় শেষে আপনি পারবেন

  • কোনো লুপ ছাড়াই পুরো কলামের ওপর পাটিগণিত (arithmetic) চালিয়ে নতুন কলাম বানাতে, এবং কেন লুপ লাগে না তা ব্যাখ্যা করতে
  • কোনো কলামে একটি Series অ্যাসাইন করার সময় ইনডেক্স অনুযায়ী মান কীভাবে বসে তা আগে থেকেই বুঝতে, এবং মিসঅ্যালাইনমেন্টের কারণে তৈরি হওয়া NaN এড়াতে
  • assign() ব্যবহার করে মূল টেবিলে হাত না দিয়েই নতুন কলাম যোগ করতে
  • np.where এবং np.select দিয়ে শর্তের ভিত্তিতে প্রতি সারির জন্য দুই বা ততোধিক মানের মধ্য থেকে সঠিকটি বেছে নিতে
  • .map() দিয়ে ডিকশনারি থেকে মান বসাতে, pd.cut দিয়ে সংখ্যাকে নির্দিষ্ট রেঞ্জে ভাগ করতে, এবং .str ও .dt দিয়ে টেক্সট ও তারিখ থেকে প্রয়োজনীয় অংশ আলাদা করতে
  • groupby(...).transform(...) ব্যবহার করে প্রতিটি সারিকে তার নিজস্ব গ্রুপের সাথে তুলনা করতে
  • কখন apply(axis=1) ব্যবহার করা সঙ্গত তা বুঝতে, এবং কেন এটি একদম শেষ উপায় হিসেবে রাখা উচিত তা ব্যাখ্যা করতে
  • ফিল্টার করা টেবিলে নতুন কলাম যোগ করার ক্ষেত্রে পান্ডাস ৩-এর Copy-on-Write কীভাবে কাজ করে তা ব্যাখ্যা করতে

আগে যা জানা লাগবে: গ্রুপিং ও অ্যাগ্রিগেশন (grouping and aggregation)।


প্রথমে ফাইলটি তৈরি করে নিন

এই অধ্যায়ের প্রতিটি উদাহরণে orders.csv ফাইলটি পড়া হবে, যা চতুর্থ অধ্যায় থেকেই ব্যবহৃত হয়ে আসছে। আপনার কাছে ফাইলটি না থাকলে, নিজের প্রজেক্ট ফোল্ডারে ঠিক এই লাইনগুলো দিয়ে ফাইলটি তৈরি করুন:

text
order_id,date,branch,product,category,quantity,price
1001,2024-03-01,north,pen,stationery,12,15.0
1002,2024-03-01,south,notebook,stationery,5,60.0
1003,2024-03-02,north,bag,accessories,2,850.0
1004,2024-03-02,east,bottle,accessories,7,120.0
1005,2024-03-03,north,eraser,stationery,30,8.0
1006,2024-03-03,south,bag,accessories,1,850.0
1007,2024-03-04,east,pen,stationery,20,15.0
1008,2024-03-04,north,bottle,accessories,4,120.0

কিছু উদাহরণে NumPy ব্যবহার করা হবে, যা পান্ডাসের সাথেই ইনস্টল হয়ে থাকে — শুধু import numpy as np লিখে নিলেই চলবে।


কোড লেখার আগে পরিকল্পনা

একটি নতুন কলাম তৈরি করা যেন একটি ছোট প্রোগ্রাম লেখার মতো। পাঁচ মিনিটের একটু পরিকল্পনা আপনাকে পরবর্তীতে ঘণ্টার পর ঘণ্টা NaN কোথা থেকে এলো তা খোঁজার যন্ত্রণা থেকে বাঁচাবে।

১. প্রতিটি কলামের প্রশ্নটি এক বাক্যে বলুন। দোকানের মালিক যা চেয়েছেন, তার প্রতিটি কলামের জন্য পরিকল্পনার একটি করে লাইন তৈরি করুন:

  • revenue — অর্ডারটি থেকে কত টাকা এসেছে। তৈরি হবে quantity × price থেকে।
  • size — যদি রেভিনিউ অন্তত ৫০০ হয় তবে "big", অন্যথায় "small"। তৈরি হবে revenue থেকে।
  • manager — অর্ডারটি যে ব্রাঞ্চ থেকে এসেছে তার দায়িত্বে কে আছেন। লুকআপ টেবিল (lookup table) ব্যবহার করে branch থেকে তৈরি হবে।
  • weekday — সপ্তাহের কোন বারে অর্ডারটি দেওয়া হয়েছিল। তৈরি হবে date থেকে।
  • branch_share — ব্রাঞ্চের মোট আয়ের কত অংশ এই অর্ডারের রেভিনিউ। তৈরি হবে revenue এবং branch থেকে।

খেয়াল করে দেখুন তাদের সবার মধ্যে একটি সাধারণ বৈশিষ্ট্য রয়েছে: প্রতিটি সারির জন্য একটি করে মান। একটি নতুন কলামের দৈর্ঘ্য সবসময় টেবিলের সমান হতে হবে। অষ্টম অধ্যায়ের সাথে এখানেই মূল পার্থক্য — সেখানে groupby(...).sum() প্রতি গ্রুপের জন্য একটি করে মান দিয়েছিল। আপনি যা হিসাব করছেন তাতে যদি টেবিলের মোট সারির চেয়ে কম মান থাকে, তবে তা এখনও কোনো কলাম হতে পারেনি।

২. হাত দেওয়ার আগেই ইনপুটটি দেখে নিন। আপনি কোন টুল ব্যবহার করতে পারবেন তা নির্ভর করে শুরুর কলামগুলোর ধরনের (dtypes) ওপর:

python
import pandas as pd

orders = pd.read_csv("orders.csv")

print(orders.shape)
print(orders.dtypes)
text
(8, 7)
order_id      int64
date            str
branch          str
product         str
category        str
quantity      int64
price       float64
dtype: object

আটটি সারি; কাজ শেষেও সারির সংখ্যা আটই থাকতে হবে। quantity হলো int64 এবং price হলো float64, তাই তাদের গুণ করলে বাস্তব পাটিগণিতের মতো গুণ হবে। date হলো str — অর্থাৎ এমন টেক্সট যা দেখতে তারিখের মতো। সাধারণ টেক্সটের কোনো বার (weekday) থাকে না, তাই .dt ব্যবহারের আগে এটিকে রূপান্তর করে নিতে হবে। এখনই এটি খেয়াল করা আপনাকে পরবর্তীতে AttributeError পাওয়া থেকে বাঁচাবে।

৩. আউটপুটের একটি খসড়া আঁকুন এবং এক সারি হাতে হিসাব করুন। কোনো কোড চালানোর আগেই ঠিক করুন দুটি সারি কেমন রূপ নেবে — একটি সাধারণ সারি এবং একটি ব্যতিক্রমী সারি:

text
product  quantity  price  revenue  size
pen            12   15.0    180.0  small
bag             2  850.0   1700.0  big

12 × 15.0 = 180.0, এবং ১৮০ হলো ৫০০-এর নিচে, তাই small। হাতে যাচাই করা একটি সারি হলো আপনার পরীক্ষার কষ্টিপাথর: কোড চলার পর যদি ০ নম্বর সারিতে 180.0 এবং small না আসে, তবে কোড দেখতে যত ভালোই লাগুক না কেন, তা ভুল।

৪. নিয়মের ধরনের সাথে মানানসই টুলটি বেছে নিন। প্রায় প্রতিটি নতুন কলামই নিচের কোনো একটি ক্যাটাগরিতে পড়ে:

  1. কলামগুলোর বা কোনো সংখ্যার ওপর পাটিগণিত → পুরো কলামে + - * /। উদাহরণ: quantity * price।
  2. শর্তের ভিত্তিতে দুটি মানের একটি বেছে নেওয়া → np.where। উদাহরণ: "big" অথবা "small"।
  3. ক্রমানুসারে শর্ত মিলিয়ে একাধিক মানের একটি বেছে নেওয়া → np.select। উদাহরণ: "bulk", "normal", "few"।
  4. নির্দিষ্ট টেবিল থেকে মান খুঁজে আনা → .map(dict)। উদাহরণ: branch থেকে manager।
  5. সংখ্যা কোন রেঞ্জের মধ্যে পড়ে তা নির্ধারণ করা → pd.cut। উদাহরণ: quantity থেকে band।
  6. টেক্সটের কোনো অংশ কেটে নেওয়া → .str মেথডসমূহ। উদাহরণ: ক্যাটাগরির প্রথম কয়েকটি অক্ষর।
  7. তারিখের কোনো অংশ নেওয়া → প্রথমে pd.to_datetime, তারপর .dt। উদাহরণ: সপ্তাহের বার।
  8. সারির নিজস্ব গ্রুপের সাথে তুলনা করা → groupby(...).transform(...)। উদাহরণ: ব্রাঞ্চের মোট বিক্রিতে অর্ডারের অনুপাত।
  9. ওপরের কোনোটিই প্রযোজ্য না হলে → apply(axis=1)। উদাহরণ: এমন অনিয়মিত নিয়ম যা ওপরের কোনো টুলে ফেলা যায় না।

তালিকাটি ওপর থেকে পড়া শুরু করুন এবং প্রথম যে টুলটি আপনার নিয়মের সাথে মিলে যায় সেখানেই থামুন। এই ক্রমটি ইচ্ছাকৃতভাবে সাজানো হয়েছে: প্রথম আটটি টুল পুরো কলামের ওপর একবারে কাজ করে; আর শেষেরটি ফিরে যায় পাইথনে প্রতি সারিতে একবার করে ফাংশন কল করার ধীরগতির নিয়মে।

৫. পরে কী কী যাচাই করবেন তা ঠিক করুন। প্রতিবার তিনটি জিনিস পরীক্ষা করবেন: সারির সংখ্যা বদলায়নি; নতুন কলামের ধরন (dtype) আপনার প্রত্যাশামতো হয়েছে; এবং সেটির ওপর isna().sum() শূন্য হয়েছে, অথবা ঠিক ততগুলো মিসিং মান আছে যার ব্যাখ্যা আপনি দিতে পারেন। সেই সাথে হাতে হিসাব করা সারিটি মিলিয়ে দেখা।


পুরো কলামের ওপর পাটিগণিত (Arithmetic)

পান্ডাস পদ্ধতিতে রেভিনিউ কলাম তৈরির রূপটি দেখুন:

python
import pandas as pd

orders = pd.read_csv("orders.csv")

orders["revenue"] = orders["quantity"] * orders["price"]

print(orders[["product", "quantity", "price", "revenue"]])
text
product  quantity  price  revenue
0       pen        12   15.0    180.0
1  notebook         5   60.0    300.0
2       bag         2  850.0   1700.0
3    bottle         7  120.0    840.0
4    eraser        30    8.0    240.0
5       bag         1  850.0    850.0
6       pen        20   15.0    300.0
7    bottle         4  120.0    480.0

০ নম্বর সারিতে দেখা যাচ্ছে 180.0 — যা আমাদের হাতে হিসাব করা মানের সমান। কোনো লুপ নেই, কোনো ইনডেক্স নিয়ে টানাটানি নেই।

কেন এটি কাজ করে। orders["quantity"] হলো আটটি সংখ্যার একটি Series; একইভাবে orders["price"]-ও একটি সিরিজ। আপনি যখন দুটি Series-এর মাঝখানে * বসান, তখন পান্ডাস তাদের মানগুলোকে ইনডেক্স লেবেল অনুযায়ী জোড়া লাগায় — লেবেল ০-এর সাথে লেবেল ০, লেবেল ১-এর সাথে লেবেল ১ — এবং প্রতিটি জোড়া গুণ করে। লুপ এখনও ঘটে, তবে তা পাইথনে নয়, বরং পান্ডাসের ভেতরের অপ্টিমাইজড ও কম্পাইল করা কোডে। এজন্যই এটি অত্যন্ত দ্রুত, আর এটি কখনোই ভুল সারির তথ্য পড়তে পারে না: কারণ এখানে তালগোল পাকানোর মতো কোনো ম্যানুয়াল কাউন্টার নেই।

তারপর orders["revenue"] = ... কলামের নামের ওপর ভিত্তি করে দুটি কাজের একটি করে:

  • যদি revenue আগে থেকে না থাকে, তবে এটি ডান প্রান্তে একটি নতুন কলাম হিসেবে যুক্ত হয়;
  • যদি এটি আগে থেকেই থাকে, তবে এটি পুরোনো কলামটি নিঃশব্দে প্রতিস্থাপন করে। কোনো "আপনি কি নিশ্চিত?" ধরনের প্রশ্ন আসে না।

ফলাফলের ধরন গাণিতিক নিয়মে নির্ধারিত হয়: int64 × float64 গুণ করলে পাওয়া যায় float64, এজন্যই revenue-তে 180-এর বদলে 180.0 দেখা যাচ্ছে।

একটি কলামের সাথে একক কোনো সংখ্যাও যুক্ত করা যায়। সংখ্যাটি তখন প্রতিটি সারির জন্য ব্যবহৃত হয় — পান্ডাস একে বলে ব্রডকাস্টিং (broadcasting):

python
orders["price_with_vat"] = (orders["price"] * 1.15).round(2)

print(orders[["product", "price", "price_with_vat"]].head(4))
text
product  price  price_with_vat
0       pen   15.0           17.25
1  notebook   60.0           69.00
2       bag  850.0          977.50
3    bottle  120.0          138.00

বন্ধনীটি গুরুত্বপূর্ণ: .round(2) গুণের সম্পূর্ণ ফলাফলের ওপর প্রয়োগ হয়, যা আবারও একটি Series। এখানে প্রতিটি ধাপ একটি কলাম গ্রহণ করে এবং একটি কলাম ফেরত দেয়, ফলে আপনি একের পর এক মেথড চেইনিং করে যেতে পারেন।


ইনডেক্স ঠিক করে কোন মান কোথায় বসবে

লেবেল অনুযায়ী মান মেলানোর এই নিয়মটি কেবল পাটিগণিতের জন্যই নয়। কোনো কলামে একটি Series অ্যাসাইন করার সময়ও লেবেল দেখে মান বসানো হয়। অধিকাংশ সময় আপনি এটি টেরই পাবেন না, কারণ নতুন মানগুলো সাধারণত একই টেবিল থেকেই আসে এবং তাদের লেবেলগুলো হুবহু মিলে যায়। কিন্তু যেদিন লেবেল মিলবে না, সেদিন এটি স্পষ্ট নজরে আসবে।

ধরুন আপনি এমন একটি কলাম চান যেখানে কেবল উত্তর ব্রাঞ্চের অর্ডারগুলোর কোয়ান্টিটি থাকবে:

python
north = orders[orders["branch"] == "north"]

orders["north_qty"] = north["quantity"]

print(orders[["branch", "quantity", "north_qty"]])
text
branch  quantity  north_qty
0  north        12       12.0
1  south         5        NaN
2  north         2        2.0
3   east         7        NaN
4  north        30       30.0
5  south         1        NaN
6   east        20        NaN
7  north         4        4.0

north-এর লেবেলগুলো ছিল 0, 2, 4, 7। পান্ডাস একই লেবেলওয়ালা সারির পাশে প্রতিটি মান বসিয়ে দিয়েছে, আর বাকি সব সারিতে বসেছে NaN — যার অর্থ "এই লেবেলের জন্য কোনো মান নেই"। আর যেহেতু NaN একটি ফ্লোট, তাই কলামটির ধরন float64 হয়ে গেছে, যদিও কোয়ান্টিটিগুলো সব পূর্ণসংখ্যা ছিল। এটি হয়তো আপনার কাঙ্ক্ষিত ফল হতে পারে, অথবা নিজের তৈরি করা মিসিং ডেটা খোঁজার এক দীর্ঘ অনুসন্ধানের শুরু হতে পারে। কোনটি ঘটেছে তা বোঝার উপায় হলো নিয়মটি জানা: অ্যাসাইনমেন্ট পজিশন বা অবস্থান অনুযায়ী বসে না, বসে লেবেল মিলিয়ে।

এই একই নিয়ম কিন্তু আপনাকে সুরক্ষাও দেয়। সর্টিং বা সাজানো (সপ্তম অধ্যায়) একটি Series-এর ক্রম বদলে দেয় কিন্তু প্রতিটি মানের সাথে তার নিজস্ব লেবেল অক্ষুণ্ণ রাখে। ফলে সাজানো একটি Series টেবিলে ফিরিয়ে দিলে প্রতিটি মান তার মূল সারিতেই ফিরে যায়:

python
by_revenue = orders["revenue"].sort_values()
print(by_revenue.head(3))

orders["revenue_copy"] = by_revenue
print(orders[["product", "revenue", "revenue_copy"]].head(3))
print((orders["revenue_copy"] == orders["revenue"]).all())
text
0    180.0
4    240.0
6    300.0
Name: revenue, dtype: float64
    product  revenue  revenue_copy
0       pen    180.0         180.0
1  notebook    300.0         300.0
2       bag   1700.0        1700.0
True

সাজানো Series-টি 0, 4, 6 লেবেলের ক্রমানুসারে চলে — দ্বিতীয় মান 240.0 আসলে ৪ নম্বর সারির, ১ নম্বর সারির নয়। তবুও revenue_copy হুবহু revenue-এর সাথে প্রতিটি সারিতে মিলে গেছে, যা শেষ লাইনের True আটটি সারির জন্যই নিশ্চিত করে। পান্ডাস লেবেল ব্যবহার করেছে এবং সর্ট করার ক্রম উপেক্ষা করেছে। যদি এটি পজিশন অনুযায়ী বসাত, তবে ১ নম্বর সারি (নোটবুক, 300.0) পেয়ে যেত 240.0।

সাধারণ পাইথনের লিস্টের কোনো লেবেল থাকে না, তাই সেটি পজিশন অনুযায়ী বসে — আর সেক্ষেত্রে তার দৈর্ঘ্য টেবিলের দৈর্ঘ্যের সাথে হুবহু সমান হতে হয়:

python
try:
    orders["rating"] = [5, 4, 3]
except ValueError as error:
    print("ValueError:", error)
text
ValueError: Length of values (3) does not match length of index (8)

আটটি সারির জন্য তিনটি মান দিলে অনুমান করে বসানোর চেষ্টা না করে সরাসরি প্রত্যাখ্যান করা হয়। এটি সবচেয়ে নিরাপদ আচরণ, এবং বার্তাটি কেমন দেখায় তা একবার চিনে রাখা ভালো।

আমাদের এই পরীক্ষামূলক কলামগুলোর আর প্রয়োজন নেই। drop(columns=...) এই কলামগুলো বাদ দিয়ে মূল টেবিলটি ফেরত দেয়:

python
orders = orders.drop(columns=["north_qty", "revenue_copy", "price_with_vat"])
print(list(orders.columns))
text
['order_id', 'date', 'branch', 'product', 'category', 'quantity', 'price', 'revenue']

assign(): মূল টেবিল না বদলে নতুন কলাম যোগ করা

orders["revenue"] = ... লিখলে সরাসরি orders টেবিলটি পরিবর্তিত হয়। অনেক সময় আপনি সেটাই চান। কিন্তু মাঝে মাঝে তা চান না — যেমন আপনি হয়তো কয়েকটি কলাম পরীক্ষা করে দেখতে চান, বা কোনো রিপোর্টের টেবিল বানাতে চান, কিন্তু মূল টেবিলটি ফাইল থেকে যেমন পড়া হয়েছিল ঠিক তেমনই অক্ষত রাখতে চান। assign() অতিরিক্ত কলামসহ একটি নতুন DataFrame তৈরি করে ফেরত দেয় এবং মূল টেবিলটিকে অপরিবর্তিত রাখে:

python
import numpy as np
import pandas as pd

orders = pd.read_csv("orders.csv")

report = orders.assign(
    revenue=orders["quantity"] * orders["price"],
    vat=lambda d: (d["revenue"] * 0.15).round(2),
)

print(report[["product", "revenue", "vat"]].head(3))
print("revenue" in orders.columns)
text
product  revenue    vat
0       pen    180.0   27.0
1  notebook    300.0   45.0
2       bag   1700.0  255.0
False

এখানে তিনটি বিষয় খেয়াল করার মতো। (শুরুতে import numpy as np লেখা হয়েছে পরবর্তী সেকশনের সুবিধার জন্য।)

আর্গুমেন্টের নামগুলোই কলামের নাম হয়ে যায় — revenue=... লিখলে revenue নামের কলাম তৈরি হয়। এজন্যই assign() ব্যবহার করার সময় কলামের নামগুলো বৈধ পাইথন ভেরিয়েবল নামের মতো হতে হয়।

vat একটি lambda হিসেবে লেখা হয়েছে। এটি এভাবে লেখার কারণ: vat বের করতে revenue দরকার, কিন্তু revenue কলামটি মূল orders-এ নেই — এটি কেবল নতুন তৈরি হতে থাকা টেবিলেই বিদ্যমান। একটি lambda d: ... সেই নতুন তৈরি হওয়া টেবিলটিকে d হিসেবে গ্রহণ করে, তাই d["revenue"] সেখানে আগে থেকেই থাকে। একটিমাত্র assign()-এর ভেতরে ওপর থেকে নিচে কলাম যোগ হয়, এবং প্রতিটি lambda তার ওপরের কলামগুলোকে দেখতে পায়।

আর শেষ লাইনটি ছাপে False: অর্থাৎ orders-এ কোনো revenue কলাম তৈরি হয়নি। আপনার অজান্তে কোনো কিছু বদলায়নি।


np.where: দুটি মানের একটি বেছে নেওয়া

"রেভিনিউ যদি অন্তত ৫০০ হয় তবে big, অন্যথায় small" — এটি প্রতিটি সারির জন্য একটি if/else শর্ত। এখানে পাইথনের সাধারণ if লিখতে গেলে আবার লুপে ফিরে যেতে হবে, কারণ একটি if-এর জন্য দরকার একক কোনো True বা False, অথচ পুরো কলামের তুলনা করলে পাওয়া যায় আটটি বুলিয়ান মান (অধ্যায় পাঁচের সেই "truth value of a Series is ambiguous" ত্রুটি)।

NumPy-এর where হলো এমন একটি if/else যা পুরো কলামের ওপর একবারে কাজ করে: যেখানে (where) শর্তটি True, সেখানে প্রথম মানটি নিন; আর যেখানে মিথ্যা, সেখানে দ্বিতীয়টি।

python
report["size"] = np.where(report["revenue"] >= 500, "big", "small")

print(report[["product", "revenue", "size"]])
text
product  revenue   size
0       pen    180.0  small
1  notebook    300.0  small
2       bag   1700.0    big
3    bottle    840.0    big
4    eraser    240.0  small
5       bag    850.0    big
6       pen    300.0  small
7    bottle    480.0  small

০ নম্বর সারিতে এসেছে small, ২ নম্বর সারিতে big — দুটিই আমাদের পরিকল্পনার খসড়ার সাথে মিলে গেছে। শর্তটি সেই একই ধরনের বুলিয়ান মাস্ক যা আপনি পঞ্চম অধ্যায়ে বানিয়েছিলেন; np.where কেবল প্রতিটি True/False-কে একটি মানে রূপান্তরিত করে। শর্তের প্রান্তিক মানটি ভালোভাবে যাচাই করে নিন: >= 500 মানে ঠিক ৫০০ টাকার কোনো অর্ডার হলে তা হবে big। এটি কোনো ভুলবশত নয়, বরং জেনে-বুঝে সচেতনভাবে ঠিক করুন।

দুটির বেশি মান: np.select

কোয়ান্টিটি অনুযায়ী তিনটি স্তর — ২০ বা তার বেশি হলে "bulk", ৫ বা তার বেশি হলে "normal", অন্যথায় "few"। np.select শর্তগুলোর একটি তালিকা এবং তাদের মানগুলোর একটি তালিকা গ্রহণ করে, আর কোনো শর্তের সাথেই না মিললে একটি default মান ব্যবহার করে:

python
conditions = [
    report["quantity"] >= 20,
    report["quantity"] >= 5,
]
labels = ["bulk", "normal"]

report["tier"] = np.select(conditions, labels, default="few")

print(report[["product", "quantity", "tier"]])
text
product  quantity    tier
0       pen        12  normal
1  notebook         5  normal
2       bag         2     few
3    bottle         7  normal
4    eraser        30    bulk
5       bag         1     few
6       pen        20    bulk
7    bottle         4     few

প্রথম যে শর্তটি True হয়, সেটিই কার্যকর হয়। এজন্যই সবচেয়ে কঠোর শর্তটি প্রথমে রাখতে হয়। শর্তের ক্রমটি উল্টে দিলে ইরেজারের ৩০ কোয়ান্টিটির কী অবস্থা হয় দেখুন:

python
wrong = np.select(
    [report["quantity"] >= 5, report["quantity"] >= 20],
    ["normal", "bulk"],
    default="few",
)
print(wrong)
text
['normal' 'normal' 'few' 'normal' 'normal' 'few' 'normal' 'few']

প্রতিটি অর্ডারই হয় normal অথবা few; কোনোটিই bulk পায়নি, কারণ ৩০ এবং ২০ উভয়ই >= 5 শর্ত পূরণ করে এবং সেই শর্তটি আগেই পরীক্ষা করা হয়েছিল। কোনো ত্রুটি আসেনি, কিন্তু কলামটি ভুল হয়ে গেছে — এজন্যই পরিকল্পনার সময় হাতে করা হিসাবে একটি চরম বা প্রান্তিক মান অন্তর্ভুক্ত করা দরকার।


.map(): ডিকশনারি দেখে মান বসানো

ম্যানেজারের নাম কোনো সূত্র থেকে হিসাব করে বের করা যায় না। এটি একটি পূর্বনির্ধারিত তথ্য যা আপনি জানেন: আশা উত্তর ব্রাঞ্চ চালান, ওমর দক্ষিণ ব্রাঞ্চ, লিনা পূর্ব ব্রাঞ্চ। যখন নিয়মটি হয় "খুঁজে বের করো", তখন একটি ডিকশনারি তৈরি করুন এবং কলামটিকে সেটির ওপর .map() করে দিন:

python
manager_of = {
    "north": "Asha",
    "south": "Omar",
    "east": "Lina",
}

report["manager"] = report["branch"].map(manager_of)

print(report[["branch", "manager"]])
print(report["manager"].isna().sum())
text
branch manager
0  north    Asha
1  south    Omar
2  north    Asha
3   east    Lina
4  north    Asha
5  south    Omar
6   east    Lina
7  north    Asha
0

প্রতিটি ব্রাঞ্চ ডিকশনারিতে থাকা তার নিজ নিজ মান — অর্থাৎ ম্যানেজারের নাম — দিয়ে প্রতিস্থাপিত হয়েছে। দ্বিতীয় প্রিন্টটি হলো আমাদের পরিকল্পনার সেই যাচাই, আর এর গুরুত্ব আপাতদৃষ্টির চেয়ে অনেক বেশি, কারণ ডিকশনারিতে কোনো কী খুঁজে না পেলে .map() কী আচরণ করে তা বোঝা জরুরি। ধরুন ডিকশনারিটি পূর্ব (east) ব্রাঞ্চ খোলার আগে লেখা হয়েছিল:

python
old_managers = {"north": "Asha", "south": "Omar"}

print(report["branch"].map(old_managers).isna().sum())
text
2

কোনো ত্রুটি আসেনি। পূর্ব ব্রাঞ্চের দুটি অর্ডারে নীরবে NaN বসে গেছে। আটটি সারির টেবিলে আপনি হয়তো এটি সাথে সাথে ধরে ফেলবেন; কিন্তু আশি হাজার সারিতে তা চোখে পড়বে না, এবং পরবর্তীতে groupby("manager") করলে সেই অর্ডারগুলো চুপচাপ বাদ পড়ে যাবে (অষ্টম অধ্যায়ের নিয়ম: NaN কী বাদ দেওয়া হয়)। তাই প্রতিটি .map()-এর ঠিক পরেই একটি isna().sum() পরীক্ষা করে নেওয়া উচিত।


pd.cut: সংখ্যা কোন রেঞ্জের মধ্যে পড়ে

দোকানটি চায় প্রতিটি অর্ডার কোয়ান্টিটি অনুযায়ী রেঞ্জে ভাগ হোক: ৫টি পর্যন্ত হলে small, ৬ থেকে ১৫টি হলে medium, আর ১৫টির বেশি হলে large। আপনি এটি np.select দিয়েও লিখতে পারেন, তবে "কোন রেঞ্জে পড়ে" এই কাজের জন্য নিজস্ব ডেডিকেটেড টুল রয়েছে। pd.cut প্রতিটি রেঞ্জের প্রান্তবিন্দু (edges) এবং প্রতিটি রেঞ্জের জন্য একটি করে লেবেল গ্রহণ করে:

python
report["band"] = pd.cut(
    report["quantity"],
    bins=[0, 5, 15, np.inf],
    labels=["small", "medium", "large"],
)

print(report[["product", "quantity", "band"]])
text
product  quantity    band
0       pen        12  medium
1  notebook         5   small
2       bag         2   small
3    bottle         7  medium
4    eraser        30   large
5       bag         1   small
6       pen        20   large
7    bottle         4   small

চারটি প্রান্তবিন্দু তিনটি রেঞ্জ তৈরি করে: (0, 5], (5, 15] এবং (15, ∞]। গোল বন্ধনীর অর্থ "অন্তর্ভুক্ত নয়", আর তৃতীয় বন্ধনীর অর্থ "অন্তর্ভুক্ত" — অর্থাৎ প্রতিটি রেঞ্জ তার ডান প্রান্তকে অন্তর্ভুক্ত করে। এজন্যই নোটবুকের ঠিক ৫ কোয়ান্টিটির অর্ডারটি small হয়েছে, medium নয়। np.inf হলো অসীম, এমন এক সর্বোচ্চ সীমা যা কোনো কোয়ান্টিটি অতিক্রম করতে পারবে না।

এর ফলাফল এমন একটি টাইপ পায় যা আপনি আগে দেখেননি:

python
print(report["band"].dtype)
print(report["band"].cat.categories.tolist())
text
category
['small', 'medium', 'large']

একটি category কলাম মনে রাখে যে small < medium < large। তাই band দিয়ে সাজালে তা বর্ণানুক্রমিক না হয়ে এই অর্থপূর্ণ ক্রমে সাজায় — যা সাধারণত যেকোনো রিপোর্টে দরকার হয়।


টেক্সট আর তারিখ: .str ও .dt

টেক্সটের অংশ নেওয়া

পঞ্চম অধ্যায়ে ফিল্টার করার জন্য .str.contains ব্যবহার করা হয়েছিল। কলাম তৈরির ক্ষেত্রেও সেই একই .str অ্যাক্সেসর কাজ করে: পাইথনে আপনি স্ট্রিংয়ের যেসব মেথড জানেন, তার সবগুলো একবারে সব মানের ওপর প্রয়োগ করা যায়। ধরুন প্রতিটি অর্ডারের জন্য STA-1001-এর মতো একটি কোড প্রয়োজন — যেখানে থাকবে ক্যাটাগরির প্রথম তিনটি বড় হাতের অক্ষর, একটি হাইফেন, এবং তারপর অর্ডার নম্বর:

python
report["code"] = (
    report["category"].str[:3].str.upper()
    + "-"
    + report["order_id"].astype(str)
)

print(report[["category", "order_id", "code"]].head(3))
text
category  order_id      code
0   stationery      1001  STA-1001
1   stationery      1002  STA-1002
2  accessories      1003  ACC-1003

.str[:3] প্রতিটি মানের প্রথম তিনটি অক্ষর কাটে, .str.upper() প্রতিটিকে বড় হাতের অক্ষরে রূপান্তর করে, আর টেক্সট কলামগুলোর মাঝে + সারি ধরে ধরে জোড়া লাগায়। order_id একটি সংখ্যা, আর সংখ্যার সাথে টেক্সট যোগ করা যায় না, তাই .astype(str) দিয়ে সেটিকে আগে টেক্সটে বদলে নেওয়া হয়েছে।

তারিখের অংশ নেওয়া

পরিকল্পনার সময়ই সতর্ক করা হয়েছিল যে date হলো str। এটিকে pd.to_datetime দিয়ে রূপান্তর করে নিলেই .dt অ্যাক্সেসর ব্যবহারের পথ খুলে যায়:

python
report["date"] = pd.to_datetime(report["date"])
print(report["date"].dtype)

report["weekday"] = report["date"].dt.day_name()

print(report[["date", "weekday"]].head(4))
text
datetime64[us]
        date   weekday
0 2024-03-01    Friday
1 2024-03-01    Friday
2 2024-03-02  Saturday
3 2024-03-02  Saturday

কলামটি এখন datetime64 — অর্থাৎ সাধারণ লেখার বদলে একটি সত্যিকারের তারিখ। (এখানে [us] হলো পান্ডাস ৩-এর নির্দিষ্ট প্রিসিশন; এটি নিয়ে ভাবার কিছু নেই।) একটি প্রকৃত তারিখ থেকে .dt আপনাকে .dt.day_name(), .dt.month, .dt.year, .dt.day এবং আরও অনেক কিছু দেয়। আর রূপান্তরের কাজটি একবারই করা হয়েছে এবং তা date-এই ফেরত রাখা হয়েছে, যাতে পরবর্তী প্রতিটি ধাপ একটি তারিখ পায়, কোনো স্ট্রিং নয়।


transform: প্রতিটি সারিকে তার গ্রুপের সাথে তুলনা করা

দোকানদারের শেষ অনুরোধটি ছিল — প্রতিটি ব্রাঞ্চের মোট বিক্রিতে সেই ব্রাঞ্চের প্রতিটি অর্ডারের অংশ কত। এর জন্য একই সারিতে দুটি জিনিস দরকার: অর্ডারের নিজস্ব রেভিনিউ, এবং তার ব্রাঞ্চের মোট রেভিনিউ। অষ্টম অধ্যায় আমাদের মোট হিসাব বের করতে শিখিয়েছিল:

python
print(report.groupby("branch")["revenue"].sum())
text
branch
east     1140.0
north    2600.0
south    1150.0
Name: revenue, dtype: float64

কিন্তু এটি হলো ব্রাঞ্চপ্রতি একটি করে মোট তিনটি মান, আর আমাদের টেবিলে রয়েছে আটটি সারি। আটটি সংখ্যাকে তিনটি সংখ্যা দিয়ে ভাগ করা সম্ভব নয়। আমাদের যা দরকার তা হলো সেই ব্রাঞ্চের মোট যোগফলটি যেন সেই ব্রাঞ্চের প্রতিটি সারিতে পুনরাবৃত্ত হয়ে বসে। ঠিক এই কাজটিই করে transform:

python
branch_total = report.groupby("branch")["revenue"].transform("sum")
print(branch_total)
text
0    2600.0
1    1150.0
2    2600.0
3    1140.0
4    2600.0
5    1150.0
6    1140.0
7    2600.0
Name: revenue, dtype: float64

একই গ্রুপিং, একই "sum", কিন্তু এর ফলে টেবিলের নিজস্ব লেবেলসহ আটটি মান পাওয়া গেছে — উত্তর ব্রাঞ্চের প্রতিটি সারিতে বসেছে 2600, আর দক্ষিণ ব্রাঞ্চের প্রতিটি সারিতে 1150। এখন এটি সাধারণ কলামের পাটিগণিত:

python
report["branch_share"] = (report["revenue"] / branch_total).round(3)

print(report[["branch", "product", "revenue", "branch_share"]])
text
branch   product  revenue  branch_share
0  north       pen    180.0         0.069
1  south  notebook    300.0         0.261
2  north       bag   1700.0         0.654
3   east    bottle    840.0         0.737
4  north    eraser    240.0         0.092
5  south       bag    850.0         0.739
6   east       pen    300.0         0.263
7  north    bottle    480.0         0.185

যাচাই করে দেখুন: উত্তর ব্রাঞ্চে ব্যাগের অর্ডারটি হলো 1700 / 2600 = 0.654। আর প্রতিটি ব্রাঞ্চের ভেতরের অনুপাতগুলো যোগ করলে অবশ্যই ১ হতে হবে:

python
print(report.groupby("branch")["branch_share"].sum())
text
branch
east     1.0
north    1.0
south    1.0
Name: branch_share, dtype: float64

মনে রাখার নিয়মটি হলো: agg/sum একটি গ্রুপকে সংকুচিত করে এক সারিতে আনে; কিন্তু transform মূল টেবিলের প্রতিটি সারির জন্য একটি করে মান ফেরত দেয়। সারসংক্ষেপ টেবিলের জন্য প্রথমটি ব্যবহার করুন, আর নতুন কলাম তৈরির জন্য দ্বিতীয়টি।


apply(axis=1): একদম শেষ উপায়

কখনও কখনও নিয়ম সত্যিই অদ্ভুত বা অনিয়মিত হতে পারে। ধরুন ডেলিভারি ফি হলো: উত্তর ব্রাঞ্চে — যা গুদামের পাশের প্রধান শাখা — রেভিনিউ অন্তত ৫০০ হলে ফি ফ্রি, অন্যথায় ৬০; আর অন্যান্য ব্রাঞ্চে ১২০, তবে ১০০০-এর ওপরে ফ্রি। আপনি এটিকে একটি সাধারণ পাইথন ফাংশন হিসেবে লিখে প্রতিটি সারির ওপর apply করতে পারেন:

python
def delivery_fee(row):
    if row["branch"] == "north":
        return 0 if row["revenue"] >= 500 else 60
    return 0 if row["revenue"] > 1000 else 120


report["fee"] = report.apply(delivery_fee, axis=1)

print(report[["branch", "revenue", "fee"]])
text
branch  revenue  fee
0  north    180.0   60
1  south    300.0  120
2  north   1700.0    0
3   east    840.0  120
4  north    240.0   60
5  south    850.0  120
6   east    300.0  120
7  north    480.0   60

axis=1-এর অর্থ হলো "ফাংশনটিকে একবারে একটি করে সারি দিন"। ভেতরে, row হলো একটি Series যার লেবেলগুলো হলো কলামের নাম, তাই row["branch"] নিখুঁতভাবে কাজ করে।

এটি পড়তে খুব সহজ, আর সেটাই এর সবচেয়ে বড় আকর্ষণ। কিন্তু এর মূল্য হলো, এটি অধ্যায়ের শুরুর সেই ধীরগতির লুপে ফিরে যায়: পান্ডাস প্রতিটি সারির জন্য একটি করে Series অবজেক্ট তৈরি করে এবং প্রতি সারির জন্য একবার করে আপনার পাইথন ফাংশন কল করে। আটটি সারিতে এটি কিছুই না; কিন্তু দশ লাখ সারিতে এটি চোখের পলক আর কফি বিরতির মধ্যকার পার্থক্যের সমান।

তাই apply-এর দিকে হাত বাড়ানোর আগে, পুরো কলামের ওপর কাজ করা টুলগুলো দিয়ে নিয়মটি প্রকাশ করার চেষ্টা করুন। এই নিয়মটি আসলে "তিনটি ক্ষেত্র, ক্রমানুসারে যাচাই" — অর্থাৎ np.select:

python
in_north = report["branch"] == "north"

fee_fast = np.select(
    [in_north & (report["revenue"] >= 500), in_north, report["revenue"] > 1000],
    [0, 60, 0],
    default=120,
)

print((fee_fast == report["fee"]).all())
text
True

হুবহু একই ফলাফল, কিন্তু কোনো পাইথন ফাংশন প্রতি সারিতে কল করতে হয়নি। apply(axis=1) কেবল এমন নিয়মের জন্যই তুলে রাখুন যা সত্যিকার অর্থেই এভাবে লেখা যায় না — যেমন বাইরের কোনো এপিআই ফাংশন কল করা, বা একাধিক ধাপে জটিল কোনো স্ট্রিং পার্স করা — এবং এর দিকে হাত বাড়ানোকে আরও একবার ভেবে দেখার সংকেত হিসেবে গ্রহণ করুন।


ফিল্টার করা টেবিলে কলাম যোগ: Copy-on-Write

বাস্তবে আপনাকে প্রায়শই আগে ফিল্টার করতে হবে এবং তারপর সেই ফলাফলে নতুন কলাম যোগ করতে হবে। পান্ডাস ৩-এ এটি অত্যন্ত সহজ, আর এটি কেন এত সহজ তা জানা দরকার কারণ ইন্টারনেটের পুরোনো টিউটোরিয়ালগুলোতে ভিন্ন কথা বলা থাকে।

python
import pandas as pd

orders = pd.read_csv("orders.csv")
orders["revenue"] = orders["quantity"] * orders["price"]

north = orders[orders["branch"] == "north"]
north["revenue_k"] = north["revenue"] / 1000

print(north[["product", "revenue", "revenue_k"]])
print(list(orders.columns))
text
product  revenue  revenue_k
0     pen    180.0       0.18
2     bag   1700.0       1.70
4  eraser    240.0       0.24
7  bottle    480.0       0.48
['order_id', 'date', 'branch', 'product', 'category', 'quantity', 'price', 'revenue']

কোনো সতর্কতা আসেনি, এবং orders-এ কোনো revenue_k কলামও যোগ হয়নি। পান্ডাস ৩ ব্যবহার করে Copy-on-Write: ফিল্টারিং বা সিলেকশনের প্রতিটি ফলাফল নিজস্ব একটি স্বাধীন টেবিল হিসেবে আচরণ করে। north পরিবর্তন করলে তা কখনোই মূল orders-কে স্পর্শ করে না।

আপনি যদি পুরোনো কোড বা ফোরামের উত্তর পড়েন, তবে দেখবেন ঠিক এই প্যাটার্নটিতেই SettingWithCopyWarning ("A value is trying to be set on a copy of a slice from a DataFrame") সতর্কতা আসত, এবং orders[...].copy() লেখার পরামর্শ দেওয়া হতো। সেই সতর্কবার্তাটি এসেছিল কারণ পুরোনো পান্ডাসে মাঝে মাঝে দুটি টেবিল একই মেমরি শেয়ার করত, আর কখন করত তা কেউ সহজে বুঝতে পারত না। পান্ডাস ৩ সেই অনিশ্চয়তা দূর করেছে, এবং সতর্কবার্তাও মুছে দিয়েছে। এখন আলাদা করে .copy() লেখার প্রয়োজন নেই, যদিও লিখলে কোনো ক্ষতি নেই।

তবে Copy-on-Write যা করতে দেয় না, তা হলো দুই ধাপের চেইনের মাধ্যমে মূল orders পরিবর্তন করা। আপনি যদি কিছু সারির জন্য মূল টেবিলেই মান পরিবর্তন করতে চান, তবে এভাবে লিখলে কোনো কাজ হবে না:

python
orders[orders["branch"] == "north"]["revenue"] = 0

print(orders["revenue"].tolist())
text
[180.0, 300.0, 1700.0, 840.0, 240.0, 850.0, 300.0, 480.0]
ChainedAssignmentError: A value is being set on a copy of a DataFrame or Series through chained assignment.
Such chained assignment never works to update the original DataFrame or Series, because the intermediate object on which we are setting values always behaves as a copy (due to Copy-on-Write).

Try using '.loc[row_indexer, col_indexer] = value' instead, to perform the assignment in a single step.

See the documentation for a more detailed explanation: https://pandas.pydata.org/pandas-docs/stable/user_guide/copy_on_write.html#chained-assignment

প্রথম বন্ধনীটি একটি নতুন টেবিল বানায়, দ্বিতীয় বন্ধনীটি সেই নতুন টেবিলে মান বসায়, এবং তারপর সেই নতুন টেবিলটি হারিয়ে যায়। পান্ডাস একটি ChainedAssignmentError দিয়ে আপনাকে সতর্ক করে, এবং তালিকায় দেখা যায় revenue সম্পূর্ণ অপরিবর্তিত — উত্তর ব্রাঞ্চের অর্ডারগুলো এখনও 180.0, 1700.0, 240.0 এবং 480.0 দেখাচ্ছে। (সতর্কতাগুলো এরর স্ট্রিমে যায়, তাই আপনার টার্মিনালে এটি তালিকার নিচে না এসে ওপরেও আসতে পারে।) মূল টেবিলে মান বদলাতে হলে .loc[rows, column] দিয়ে এক ধাপে করতে হবে:

python
orders.loc[orders["branch"] == "north", "note"] = "main branch"

print(orders[["branch", "note"]])
text
branch         note
0  north  main branch
1  south          NaN
2  north  main branch
3   east          NaN
4  north  main branch
5  south          NaN
6   east          NaN
7  north  main branch

যেসব সারি শর্ত পূরণ করে সেগুলো মান পায়; প্রয়োজনে কলামটি তৈরি হয়ে যায়; আর যেসব সারি শর্ত পূরণ করে না সেগুলো NaN পায়। এক ধাপ, মূল টেবিল, কোনো সতর্কতা নেই।


একটি সম্পূর্ণ উদাহরণ

পরিকল্পনা মেনে enrich.py দোকানের মালিকের পুরো বার্তার উত্তর দেয়: পড়া, যাচাই করা, কলাম যোগ করা, এবং পুনরায় যাচাই করা।

python
import numpy as np
import pandas as pd

MANAGER_OF = {
    "north": "Asha",
    "south": "Omar",
    "east": "Lina",
}

orders = pd.read_csv("orders.csv")
rows_before = len(orders)

# 1. Conversions first, so later steps get the right types.
orders["date"] = pd.to_datetime(orders["date"])

# 2. New columns, one rule each.
orders["revenue"] = orders["quantity"] * orders["price"]
orders["size"] = np.where(orders["revenue"] >= 500, "big", "small")
orders["manager"] = orders["branch"].map(MANAGER_OF)
orders["weekday"] = orders["date"].dt.day_name()
branch_total = orders.groupby("branch")["revenue"].transform("sum")
orders["branch_share"] = (orders["revenue"] / branch_total).round(3)

# 3. Checks: same rows, nothing missing, the hand-worked row is right.
new_columns = ["revenue", "size", "manager", "weekday", "branch_share"]
assert len(orders) == rows_before
assert orders[new_columns].isna().sum().sum() == 0
assert orders.loc[0, "revenue"] == 180.0

print(orders[["order_id", "branch", "manager", "weekday", "revenue", "size", "branch_share"]])
print()
print(orders[new_columns].dtypes)
text
order_id branch manager   weekday  revenue   size  branch_share
0      1001  north    Asha    Friday    180.0  small         0.069
1      1002  south    Omar    Friday    300.0  small         0.261
2      1003  north    Asha  Saturday   1700.0    big         0.654
3      1004   east    Lina  Saturday    840.0    big         0.737
4      1005  north    Asha    Sunday    240.0  small         0.092
5      1006  south    Omar    Sunday    850.0    big         0.739
6      1007   east    Lina    Monday    300.0  small         0.263
7      1008  north    Asha    Monday    480.0  small         0.185

revenue         float64
size                str
manager             str
weekday             str
branch_share    float64
dtype: object

কেন কোডটি এভাবে লেখা হয়েছে:

  • রূপান্তরের কাজটি সবার আগে করা হয়েছে। কোনো কিছু ব্যবহারের আগেই date-কে আসল তারিখে রূপান্তর করা হয়েছে, ফলে পরে আর .dt ফেইল করার কোনো সুযোগ নেই এবং পরবর্তীতে কাউকে রূপান্তরের কথা মনে রাখতে হবে না।
  • প্রতি লাইনে একটি করে নিয়ম। প্রতিটি লাইন পরিকল্পনার একটি লাইনের উত্তর দেয়, ফলে কোনো সংখ্যা ভুল মনে হলে আপনি জানেন ঠিক কোন একটি লাইন তা তৈরি করেছে।
  • লুকআপ টেবিলটি ওপরে একটি কনস্ট্যান্ট হিসেবে রাখা হয়েছে। দোকান যখন পশ্চিম ব্রাঞ্চ খুলবে, তখন পরিবর্তনটি একটি স্পষ্ট স্থানে এক লাইনে হবে, আর কেউ তা ভুলে গেলে isna চেক জোরালোভাবে প্রোগ্রাম থামিয়ে দেবে।
  • যাচাইকরণগুলো হলো assert, সাধারণ প্রিন্ট নয়। সারির সংখ্যা বদলে গেলে, .map() কোনো ব্রাঞ্চ মিস করলে, বা ০ নম্বর সারি ১৮০ না হলে প্রোগ্রাম ভুল রিপোর্ট ছাপানোর বদলে সেখানেই থেমে যায়।
  • শেষে dtypes প্রিন্ট করা হয়েছে কারণ সঠিক টাইপ হলো উত্তরের অবিচ্ছেদ্য অংশ: revenue হওয়া উচিত ফ্লোট, branch_share ফ্লোট, আর টেক্সট কলামগুলো str।

কিছু ভাঙা অবস্থা ও তার সমাধান

KeyError: 'Price' ডান পাশের কলামের নামে বানানের ভুল হয়েছে বা বড়-ছোট হাতের অক্ষরে গড়মিল হয়েছে। list(orders.columns) প্রিন্ট করে সেখান থেকে সঠিক নামটি কপি করুন। বাঁ পাশের দিকেও নজর রাখুন: orders["revenu"] = ... লিখলে কোনো ত্রুটি আসে না — বরং আপনার কাঙ্ক্ষিত কলামের পাশে ভুল বানানের একটি নতুন কলাম তৈরি হয়। কোনো কলাম "আপডেট" করার পরেও যদি না বদলায়, তবে বানানের ভুলে তৈরি হওয়া যমজ কলাম খুঁজুন।

ValueError: Length of values (3) does not match length of index (8) আপনি এমন একটি লিস্ট (বা অ্যারে) অ্যাসাইন করেছেন যার দৈর্ঘ্য টেবিলের সারির সংখ্যার সমান নয়। লিস্টের কোনো লেবেল থাকে না, তাই পান্ডাস তা মেলাতে পারে না এবং প্রত্যাখ্যান করে। হয় টেবিলের নিজস্ব কলাম থেকে মান তৈরি করুন, নয়তো আগে len(values) == len(df) নিশ্চিত করুন।

নতুন কলামের কিছু সারিতে NaN ভরে গেছে, আর টাইপ হয়ে গেছে float64 আপনি এমন একটি Series অ্যাসাইন করেছেন যার লেবেল টেবিলের লেবেলের সাথে আংশিকভাবে মিলেছে — সাধারণত কোনো ফিল্টার করা টেবিল থেকে তৈরি সিরিজ। অ্যাসাইনমেন্ট পজিশন নয়, লেবেল দেখে বসে। আংশিক টেবিল থেকে কলাম তৈরি না করে পুরো টেবিল থেকে নতুন কলাম তৈরি করুন (যেমন np.where বা .loc[mask, "col"] = ... দিয়ে)।

revenue-তে দেখা যাচ্ছে 15.015.015.0…, অথবা TypeError: can't multiply sequence by non-int of type 'float' কলামগুলোর একটি সংখ্যা নয়, টেক্সট হিসেবে আছে। একটি int-কে স্ট্রিং দিয়ে গুণ করলে স্ট্রিংটি বারবার পুনরাবৃত্ত হয় — পাইথনের সেই নিয়ম 3 * "ab" == "ababab" — এবং পান্ডাস কোনো আপত্তি ছাড়াই সারি ধরে তা প্রয়োগ করে:

python
import pandas as pd
from io import StringIO

RAW = """product,quantity,price
pen,12,15.0
bag,2,"1,200"
"""
bad = pd.read_csv(StringIO(RAW))

print(bad.dtypes)
print(bad["quantity"] * bad["price"])
text
product       str
quantity    int64
price         str
dtype: object
0    15.015.015.015.015.015.015.015.015.015.015.015.0
1                                          1,2001,200
dtype: str

"1,200"-এর ভেতরের হাজারের কমা পুরো price কলামটিকে str বানিয়ে দিয়েছে। এজন্যই পরিকল্পনায় বলা হয়েছে পাটিগণিতের আগেই dtypes দেখে নিতে। ফাইল পড়ার সময়ই thousands="," দিয়ে এটি সমাধান করুন, অথবা pd.to_numeric দিয়ে রূপান্তর করুন:

python
good = pd.read_csv(StringIO(RAW), thousands=",")

print(good.dtypes["price"])
print(good["quantity"] * good["price"])
text
float64
0     180.0
1    2400.0
dtype: float64

AttributeError: Can only use .dt accessor with datetimelike values কলামটি এখনও টেক্সট রয়ে গেছে। প্রথমে df["date"] = pd.to_datetime(df["date"]) দিয়ে রূপান্তর করুন এবং তারপর .dt ব্যবহার করুন।

apply থেকে KeyError: 'quantity' আপনি axis=1 লিখতে ভুলে গেছেন। এটি ছাড়া apply আপনার ফাংশনে পুরো কলাম পাঠায়, ফলে row["quantity"] কলামের ভেতরে quantity নামের সারি লেবেল খুঁজতে গিয়ে ব্যর্থ হয়। axis=1 যোগ করুন — এবং তারপর নিজেকে প্রশ্ন করুন আপনার আদৌ apply লাগবে কি না।

IntCastingNaNError: Cannot convert non-finite values (NA or inf) to integer আপনি NaN যুক্ত কোনো কলামে .astype(int) কল করেছেন। একটি সাধারণ NumPy ইন্টিজার মিসিং ভ্যালু সংরক্ষণ করতে পারে না। প্রথমে ফাঁকা ঘরগুলো পূরণ করুন (ষষ্ঠ অধ্যায়), অথবা পান্ডাসের নাল-সমর্থিত ইন্টিজার টাইপ .astype("Int64") (বড় হাতের I) ব্যবহার করুন, যা মিসিং মান ধারণ করতে পারে।

ChainedAssignmentError: A value is being set on a copy of a DataFrame or Series through chained assignment. আপনি লিখেছেন df[mask]["col"] = value। Copy-on-Write-এর অধীনে প্রথম ধাপটি একটি পৃথক টেবিল তৈরি করে, ফলে মূল টেবিলটি কখনোই পরিবর্তিত হয় না। এটিকে এক ধাপে লিখুন: df.loc[mask, "col"] = value।

.map() কলামে যেখানে মান প্রত্যাশিত ছিল সেখানে NaN এসেছে কলামের কোনো একটি কী ডিকশনারিতে নেই — নতুন কোনো ব্রাঞ্চ, বানানের ভিন্নতা, বা শেষে কোনো বাড়তি স্পেস। df.loc[df["new"].isna(), "key_column"].unique() দিয়ে সেগুলো খুঁজে বের করুন, তারপর ডিকশনারি বা ডেটা ঠিক করুন।