নতুন কলাম যোগ করা — ফাইলে যে সংখ্যাগুলো নেই তা তৈরি করা
বিদ্যমান কলাম থেকে নতুন কলাম তৈরি: পুরো কলামের ওপর পাটিগণিত এবং ইনডেক্স কীভাবে প্রতিটি মান বসায়, assign, np.where ও np.select, map, pd.cut, .str ও .dt, প্রতিটি সারির সাথে গ্রুপের তুলনা করার জন্য transform, এবং শেষ উপায় হিসেবে apply।
- 1সমস্যা
- 2বোঝা
- 3উদাহরণ
- 4অনুমান
- 5নিজে করা
- 6কঠিন করা
যে সমস্যাটা আমরা সমাধান করছি
দোকানের মালিক একটি বার্তা পাঠালেন: "প্রতিটি অর্ডারের ক্ষেত্রে আমি দেখতে চাই তা থেকে কত টাকা আয় হয়েছে, অর্ডারটি বড় ছিল কি না, কোন ম্যানেজার এটির দায়িত্বে আছেন, এবং সেটি সপ্তাহের কী বার ছিল। আর প্রতিটি অর্ডারের ক্ষেত্রে, তা তার নিজস্ব ব্রাঞ্চের মোট আয়ের কত শতাংশ?"
আপনি orders.csv ফাইলটি খুললেন। সেখানে রয়েছে quantity (পরিমাণ) এবং price (দাম), branch (তিনটি দোকানের কোনটি) এবং date (তারিখ)। তিনি যা যা চেয়েছেন তার একটি তথ্যও ফাইলে সরাসরি নেই। প্রতিটি তথ্যই ফাইলে আগে থেকে থাকা কলামগুলো ব্যবহার করে তৈরি করে নিতে হবে।
আপনি যদি সাধারণ পাইথন থেকে এসে থাকেন, তবে আপনার হাত ইতিমধ্যেই জানে কী টাইপ করতে হবে — সারিগুলোর ওপর একটি লুপ চালানো:
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"]])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) ব্রাঞ্চের অর্ডারগুলো আলাদা করলেন — তখন আর তারা এক থাকে না:
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"])[0, 2, 4, 7]
KeyError: 1north-এর সারিগুলো তাদের মূল লেবেল 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 ফাইলটি পড়া হবে, যা চতুর্থ অধ্যায় থেকেই ব্যবহৃত হয়ে আসছে। আপনার কাছে ফাইলটি না থাকলে, নিজের প্রজেক্ট ফোল্ডারে ঠিক এই লাইনগুলো দিয়ে ফাইলটি তৈরি করুন:
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) ওপর:
import pandas as pd
orders = pd.read_csv("orders.csv")
print(orders.shape)
print(orders.dtypes)(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 পাওয়া থেকে বাঁচাবে।
৩. আউটপুটের একটি খসড়া আঁকুন এবং এক সারি হাতে হিসাব করুন। কোনো কোড চালানোর আগেই ঠিক করুন দুটি সারি কেমন রূপ নেবে — একটি সাধারণ সারি এবং একটি ব্যতিক্রমী সারি:
product quantity price revenue size
pen 12 15.0 180.0 small
bag 2 850.0 1700.0 big12 × 15.0 = 180.0, এবং ১৮০ হলো ৫০০-এর নিচে, তাই small। হাতে যাচাই করা একটি সারি হলো আপনার পরীক্ষার কষ্টিপাথর: কোড চলার পর যদি ০ নম্বর সারিতে 180.0 এবং small না আসে, তবে কোড দেখতে যত ভালোই লাগুক না কেন, তা ভুল।
৪. নিয়মের ধরনের সাথে মানানসই টুলটি বেছে নিন। প্রায় প্রতিটি নতুন কলামই নিচের কোনো একটি ক্যাটাগরিতে পড়ে:
- কলামগুলোর বা কোনো সংখ্যার ওপর পাটিগণিত → পুরো কলামে
+ - * /। উদাহরণ:quantity * price। - শর্তের ভিত্তিতে দুটি মানের একটি বেছে নেওয়া →
np.where। উদাহরণ:"big"অথবা"small"। - ক্রমানুসারে শর্ত মিলিয়ে একাধিক মানের একটি বেছে নেওয়া →
np.select। উদাহরণ:"bulk","normal","few"। - নির্দিষ্ট টেবিল থেকে মান খুঁজে আনা →
.map(dict)। উদাহরণ: branch থেকে manager। - সংখ্যা কোন রেঞ্জের মধ্যে পড়ে তা নির্ধারণ করা →
pd.cut। উদাহরণ: quantity থেকে band। - টেক্সটের কোনো অংশ কেটে নেওয়া →
.strমেথডসমূহ। উদাহরণ: ক্যাটাগরির প্রথম কয়েকটি অক্ষর। - তারিখের কোনো অংশ নেওয়া → প্রথমে
pd.to_datetime, তারপর.dt। উদাহরণ: সপ্তাহের বার। - সারির নিজস্ব গ্রুপের সাথে তুলনা করা →
groupby(...).transform(...)। উদাহরণ: ব্রাঞ্চের মোট বিক্রিতে অর্ডারের অনুপাত। - ওপরের কোনোটিই প্রযোজ্য না হলে →
apply(axis=1)। উদাহরণ: এমন অনিয়মিত নিয়ম যা ওপরের কোনো টুলে ফেলা যায় না।
তালিকাটি ওপর থেকে পড়া শুরু করুন এবং প্রথম যে টুলটি আপনার নিয়মের সাথে মিলে যায় সেখানেই থামুন। এই ক্রমটি ইচ্ছাকৃতভাবে সাজানো হয়েছে: প্রথম আটটি টুল পুরো কলামের ওপর একবারে কাজ করে; আর শেষেরটি ফিরে যায় পাইথনে প্রতি সারিতে একবার করে ফাংশন কল করার ধীরগতির নিয়মে।
৫. পরে কী কী যাচাই করবেন তা ঠিক করুন। প্রতিবার তিনটি জিনিস পরীক্ষা করবেন: সারির সংখ্যা বদলায়নি; নতুন কলামের ধরন (dtype) আপনার প্রত্যাশামতো হয়েছে; এবং সেটির ওপর isna().sum() শূন্য হয়েছে, অথবা ঠিক ততগুলো মিসিং মান আছে যার ব্যাখ্যা আপনি দিতে পারেন। সেই সাথে হাতে হিসাব করা সারিটি মিলিয়ে দেখা।
পুরো কলামের ওপর পাটিগণিত (Arithmetic)
পান্ডাস পদ্ধতিতে রেভিনিউ কলাম তৈরির রূপটি দেখুন:
import pandas as pd
orders = pd.read_csv("orders.csv")
orders["revenue"] = orders["quantity"] * orders["price"]
print(orders[["product", "quantity", "price", "revenue"]])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):
orders["price_with_vat"] = (orders["price"] * 1.15).round(2)
print(orders[["product", "price", "price_with_vat"]].head(4))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 অ্যাসাইন করার সময়ও লেবেল দেখে মান বসানো হয়। অধিকাংশ সময় আপনি এটি টেরই পাবেন না, কারণ নতুন মানগুলো সাধারণত একই টেবিল থেকেই আসে এবং তাদের লেবেলগুলো হুবহু মিলে যায়। কিন্তু যেদিন লেবেল মিলবে না, সেদিন এটি স্পষ্ট নজরে আসবে।
ধরুন আপনি এমন একটি কলাম চান যেখানে কেবল উত্তর ব্রাঞ্চের অর্ডারগুলোর কোয়ান্টিটি থাকবে:
north = orders[orders["branch"] == "north"]
orders["north_qty"] = north["quantity"]
print(orders[["branch", "quantity", "north_qty"]])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.0north-এর লেবেলগুলো ছিল 0, 2, 4, 7। পান্ডাস একই লেবেলওয়ালা সারির পাশে প্রতিটি মান বসিয়ে দিয়েছে, আর বাকি সব সারিতে বসেছে NaN — যার অর্থ "এই লেবেলের জন্য কোনো মান নেই"। আর যেহেতু NaN একটি ফ্লোট, তাই কলামটির ধরন float64 হয়ে গেছে, যদিও কোয়ান্টিটিগুলো সব পূর্ণসংখ্যা ছিল। এটি হয়তো আপনার কাঙ্ক্ষিত ফল হতে পারে, অথবা নিজের তৈরি করা মিসিং ডেটা খোঁজার এক দীর্ঘ অনুসন্ধানের শুরু হতে পারে। কোনটি ঘটেছে তা বোঝার উপায় হলো নিয়মটি জানা: অ্যাসাইনমেন্ট পজিশন বা অবস্থান অনুযায়ী বসে না, বসে লেবেল মিলিয়ে।
এই একই নিয়ম কিন্তু আপনাকে সুরক্ষাও দেয়। সর্টিং বা সাজানো (সপ্তম অধ্যায়) একটি Series-এর ক্রম বদলে দেয় কিন্তু প্রতিটি মানের সাথে তার নিজস্ব লেবেল অক্ষুণ্ণ রাখে। ফলে সাজানো একটি Series টেবিলে ফিরিয়ে দিলে প্রতিটি মান তার মূল সারিতেই ফিরে যায়:
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())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।
সাধারণ পাইথনের লিস্টের কোনো লেবেল থাকে না, তাই সেটি পজিশন অনুযায়ী বসে — আর সেক্ষেত্রে তার দৈর্ঘ্য টেবিলের দৈর্ঘ্যের সাথে হুবহু সমান হতে হয়:
try:
orders["rating"] = [5, 4, 3]
except ValueError as error:
print("ValueError:", error)ValueError: Length of values (3) does not match length of index (8)আটটি সারির জন্য তিনটি মান দিলে অনুমান করে বসানোর চেষ্টা না করে সরাসরি প্রত্যাখ্যান করা হয়। এটি সবচেয়ে নিরাপদ আচরণ, এবং বার্তাটি কেমন দেখায় তা একবার চিনে রাখা ভালো।
আমাদের এই পরীক্ষামূলক কলামগুলোর আর প্রয়োজন নেই। drop(columns=...) এই কলামগুলো বাদ দিয়ে মূল টেবিলটি ফেরত দেয়:
orders = orders.drop(columns=["north_qty", "revenue_copy", "price_with_vat"])
print(list(orders.columns))['order_id', 'date', 'branch', 'product', 'category', 'quantity', 'price', 'revenue']assign(): মূল টেবিল না বদলে নতুন কলাম যোগ করা
orders["revenue"] = ... লিখলে সরাসরি orders টেবিলটি পরিবর্তিত হয়। অনেক সময় আপনি সেটাই চান। কিন্তু মাঝে মাঝে তা চান না — যেমন আপনি হয়তো কয়েকটি কলাম পরীক্ষা করে দেখতে চান, বা কোনো রিপোর্টের টেবিল বানাতে চান, কিন্তু মূল টেবিলটি ফাইল থেকে যেমন পড়া হয়েছিল ঠিক তেমনই অক্ষত রাখতে চান। assign() অতিরিক্ত কলামসহ একটি নতুন DataFrame তৈরি করে ফেরত দেয় এবং মূল টেবিলটিকে অপরিবর্তিত রাখে:
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)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, সেখানে প্রথম মানটি নিন; আর যেখানে মিথ্যা, সেখানে দ্বিতীয়টি।
report["size"] = np.where(report["revenue"] >= 500, "big", "small")
print(report[["product", "revenue", "size"]])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 মান ব্যবহার করে:
conditions = [
report["quantity"] >= 20,
report["quantity"] >= 5,
]
labels = ["bulk", "normal"]
report["tier"] = np.select(conditions, labels, default="few")
print(report[["product", "quantity", "tier"]])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 হয়, সেটিই কার্যকর হয়। এজন্যই সবচেয়ে কঠোর শর্তটি প্রথমে রাখতে হয়। শর্তের ক্রমটি উল্টে দিলে ইরেজারের ৩০ কোয়ান্টিটির কী অবস্থা হয় দেখুন:
wrong = np.select(
[report["quantity"] >= 5, report["quantity"] >= 20],
["normal", "bulk"],
default="few",
)
print(wrong)['normal' 'normal' 'few' 'normal' 'normal' 'few' 'normal' 'few']প্রতিটি অর্ডারই হয় normal অথবা few; কোনোটিই bulk পায়নি, কারণ ৩০ এবং ২০ উভয়ই >= 5 শর্ত পূরণ করে এবং সেই শর্তটি আগেই পরীক্ষা করা হয়েছিল। কোনো ত্রুটি আসেনি, কিন্তু কলামটি ভুল হয়ে গেছে — এজন্যই পরিকল্পনার সময় হাতে করা হিসাবে একটি চরম বা প্রান্তিক মান অন্তর্ভুক্ত করা দরকার।
.map(): ডিকশনারি দেখে মান বসানো
ম্যানেজারের নাম কোনো সূত্র থেকে হিসাব করে বের করা যায় না। এটি একটি পূর্বনির্ধারিত তথ্য যা আপনি জানেন: আশা উত্তর ব্রাঞ্চ চালান, ওমর দক্ষিণ ব্রাঞ্চ, লিনা পূর্ব ব্রাঞ্চ। যখন নিয়মটি হয় "খুঁজে বের করো", তখন একটি ডিকশনারি তৈরি করুন এবং কলামটিকে সেটির ওপর .map() করে দিন:
manager_of = {
"north": "Asha",
"south": "Omar",
"east": "Lina",
}
report["manager"] = report["branch"].map(manager_of)
print(report[["branch", "manager"]])
print(report["manager"].isna().sum())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) ব্রাঞ্চ খোলার আগে লেখা হয়েছিল:
old_managers = {"north": "Asha", "south": "Omar"}
print(report["branch"].map(old_managers).isna().sum())2কোনো ত্রুটি আসেনি। পূর্ব ব্রাঞ্চের দুটি অর্ডারে নীরবে NaN বসে গেছে। আটটি সারির টেবিলে আপনি হয়তো এটি সাথে সাথে ধরে ফেলবেন; কিন্তু আশি হাজার সারিতে তা চোখে পড়বে না, এবং পরবর্তীতে groupby("manager") করলে সেই অর্ডারগুলো চুপচাপ বাদ পড়ে যাবে (অষ্টম অধ্যায়ের নিয়ম: NaN কী বাদ দেওয়া হয়)। তাই প্রতিটি .map()-এর ঠিক পরেই একটি isna().sum() পরীক্ষা করে নেওয়া উচিত।
pd.cut: সংখ্যা কোন রেঞ্জের মধ্যে পড়ে
দোকানটি চায় প্রতিটি অর্ডার কোয়ান্টিটি অনুযায়ী রেঞ্জে ভাগ হোক: ৫টি পর্যন্ত হলে small, ৬ থেকে ১৫টি হলে medium, আর ১৫টির বেশি হলে large। আপনি এটি np.select দিয়েও লিখতে পারেন, তবে "কোন রেঞ্জে পড়ে" এই কাজের জন্য নিজস্ব ডেডিকেটেড টুল রয়েছে। pd.cut প্রতিটি রেঞ্জের প্রান্তবিন্দু (edges) এবং প্রতিটি রেঞ্জের জন্য একটি করে লেবেল গ্রহণ করে:
report["band"] = pd.cut(
report["quantity"],
bins=[0, 5, 15, np.inf],
labels=["small", "medium", "large"],
)
print(report[["product", "quantity", "band"]])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 হলো অসীম, এমন এক সর্বোচ্চ সীমা যা কোনো কোয়ান্টিটি অতিক্রম করতে পারবে না।
এর ফলাফল এমন একটি টাইপ পায় যা আপনি আগে দেখেননি:
print(report["band"].dtype)
print(report["band"].cat.categories.tolist())category
['small', 'medium', 'large']একটি category কলাম মনে রাখে যে small < medium < large। তাই band দিয়ে সাজালে তা বর্ণানুক্রমিক না হয়ে এই অর্থপূর্ণ ক্রমে সাজায় — যা সাধারণত যেকোনো রিপোর্টে দরকার হয়।
টেক্সট আর তারিখ: .str ও .dt
টেক্সটের অংশ নেওয়া
পঞ্চম অধ্যায়ে ফিল্টার করার জন্য .str.contains ব্যবহার করা হয়েছিল। কলাম তৈরির ক্ষেত্রেও সেই একই .str অ্যাক্সেসর কাজ করে: পাইথনে আপনি স্ট্রিংয়ের যেসব মেথড জানেন, তার সবগুলো একবারে সব মানের ওপর প্রয়োগ করা যায়। ধরুন প্রতিটি অর্ডারের জন্য STA-1001-এর মতো একটি কোড প্রয়োজন — যেখানে থাকবে ক্যাটাগরির প্রথম তিনটি বড় হাতের অক্ষর, একটি হাইফেন, এবং তারপর অর্ডার নম্বর:
report["code"] = (
report["category"].str[:3].str.upper()
+ "-"
+ report["order_id"].astype(str)
)
print(report[["category", "order_id", "code"]].head(3))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 অ্যাক্সেসর ব্যবহারের পথ খুলে যায়:
report["date"] = pd.to_datetime(report["date"])
print(report["date"].dtype)
report["weekday"] = report["date"].dt.day_name()
print(report[["date", "weekday"]].head(4))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: প্রতিটি সারিকে তার গ্রুপের সাথে তুলনা করা
দোকানদারের শেষ অনুরোধটি ছিল — প্রতিটি ব্রাঞ্চের মোট বিক্রিতে সেই ব্রাঞ্চের প্রতিটি অর্ডারের অংশ কত। এর জন্য একই সারিতে দুটি জিনিস দরকার: অর্ডারের নিজস্ব রেভিনিউ, এবং তার ব্রাঞ্চের মোট রেভিনিউ। অষ্টম অধ্যায় আমাদের মোট হিসাব বের করতে শিখিয়েছিল:
print(report.groupby("branch")["revenue"].sum())branch
east 1140.0
north 2600.0
south 1150.0
Name: revenue, dtype: float64কিন্তু এটি হলো ব্রাঞ্চপ্রতি একটি করে মোট তিনটি মান, আর আমাদের টেবিলে রয়েছে আটটি সারি। আটটি সংখ্যাকে তিনটি সংখ্যা দিয়ে ভাগ করা সম্ভব নয়। আমাদের যা দরকার তা হলো সেই ব্রাঞ্চের মোট যোগফলটি যেন সেই ব্রাঞ্চের প্রতিটি সারিতে পুনরাবৃত্ত হয়ে বসে। ঠিক এই কাজটিই করে transform:
branch_total = report.groupby("branch")["revenue"].transform("sum")
print(branch_total)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। এখন এটি সাধারণ কলামের পাটিগণিত:
report["branch_share"] = (report["revenue"] / branch_total).round(3)
print(report[["branch", "product", "revenue", "branch_share"]])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। আর প্রতিটি ব্রাঞ্চের ভেতরের অনুপাতগুলো যোগ করলে অবশ্যই ১ হতে হবে:
print(report.groupby("branch")["branch_share"].sum())branch
east 1.0
north 1.0
south 1.0
Name: branch_share, dtype: float64মনে রাখার নিয়মটি হলো: agg/sum একটি গ্রুপকে সংকুচিত করে এক সারিতে আনে; কিন্তু transform মূল টেবিলের প্রতিটি সারির জন্য একটি করে মান ফেরত দেয়। সারসংক্ষেপ টেবিলের জন্য প্রথমটি ব্যবহার করুন, আর নতুন কলাম তৈরির জন্য দ্বিতীয়টি।
apply(axis=1): একদম শেষ উপায়
কখনও কখনও নিয়ম সত্যিই অদ্ভুত বা অনিয়মিত হতে পারে। ধরুন ডেলিভারি ফি হলো: উত্তর ব্রাঞ্চে — যা গুদামের পাশের প্রধান শাখা — রেভিনিউ অন্তত ৫০০ হলে ফি ফ্রি, অন্যথায় ৬০; আর অন্যান্য ব্রাঞ্চে ১২০, তবে ১০০০-এর ওপরে ফ্রি। আপনি এটিকে একটি সাধারণ পাইথন ফাংশন হিসেবে লিখে প্রতিটি সারির ওপর apply করতে পারেন:
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"]])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 60axis=1-এর অর্থ হলো "ফাংশনটিকে একবারে একটি করে সারি দিন"। ভেতরে, row হলো একটি Series যার লেবেলগুলো হলো কলামের নাম, তাই row["branch"] নিখুঁতভাবে কাজ করে।
এটি পড়তে খুব সহজ, আর সেটাই এর সবচেয়ে বড় আকর্ষণ। কিন্তু এর মূল্য হলো, এটি অধ্যায়ের শুরুর সেই ধীরগতির লুপে ফিরে যায়: পান্ডাস প্রতিটি সারির জন্য একটি করে Series অবজেক্ট তৈরি করে এবং প্রতি সারির জন্য একবার করে আপনার পাইথন ফাংশন কল করে। আটটি সারিতে এটি কিছুই না; কিন্তু দশ লাখ সারিতে এটি চোখের পলক আর কফি বিরতির মধ্যকার পার্থক্যের সমান।
তাই apply-এর দিকে হাত বাড়ানোর আগে, পুরো কলামের ওপর কাজ করা টুলগুলো দিয়ে নিয়মটি প্রকাশ করার চেষ্টা করুন। এই নিয়মটি আসলে "তিনটি ক্ষেত্র, ক্রমানুসারে যাচাই" — অর্থাৎ np.select:
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())Trueহুবহু একই ফলাফল, কিন্তু কোনো পাইথন ফাংশন প্রতি সারিতে কল করতে হয়নি। apply(axis=1) কেবল এমন নিয়মের জন্যই তুলে রাখুন যা সত্যিকার অর্থেই এভাবে লেখা যায় না — যেমন বাইরের কোনো এপিআই ফাংশন কল করা, বা একাধিক ধাপে জটিল কোনো স্ট্রিং পার্স করা — এবং এর দিকে হাত বাড়ানোকে আরও একবার ভেবে দেখার সংকেত হিসেবে গ্রহণ করুন।
ফিল্টার করা টেবিলে কলাম যোগ: Copy-on-Write
বাস্তবে আপনাকে প্রায়শই আগে ফিল্টার করতে হবে এবং তারপর সেই ফলাফলে নতুন কলাম যোগ করতে হবে। পান্ডাস ৩-এ এটি অত্যন্ত সহজ, আর এটি কেন এত সহজ তা জানা দরকার কারণ ইন্টারনেটের পুরোনো টিউটোরিয়ালগুলোতে ভিন্ন কথা বলা থাকে।
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))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 পরিবর্তন করা। আপনি যদি কিছু সারির জন্য মূল টেবিলেই মান পরিবর্তন করতে চান, তবে এভাবে লিখলে কোনো কাজ হবে না:
orders[orders["branch"] == "north"]["revenue"] = 0
print(orders["revenue"].tolist())[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] দিয়ে এক ধাপে করতে হবে:
orders.loc[orders["branch"] == "north", "note"] = "main branch"
print(orders[["branch", "note"]])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 দোকানের মালিকের পুরো বার্তার উত্তর দেয়: পড়া, যাচাই করা, কলাম যোগ করা, এবং পুনরায় যাচাই করা।
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)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" — এবং পান্ডাস কোনো আপত্তি ছাড়াই সারি ধরে তা প্রয়োগ করে:
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"])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 দিয়ে রূপান্তর করুন:
good = pd.read_csv(StringIO(RAW), thousands=",")
print(good.dtypes["price"])
print(good["quantity"] * good["price"])float64
0 180.0
1 2400.0
dtype: float64AttributeError: 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() দিয়ে সেগুলো খুঁজে বের করুন, তারপর ডিকশনারি বা ডেটা ঠিক করুন।
ধাপ ৪ / ৬ — অনুমান
যাচাই করুন
নর্থ ব্রাঞ্চের অর্ডারগুলো থেকে তৈরি একটি কলাম পুরো টেবিলে অ্যাসাইন করা হলো। কী ছাপা হবে?
from io import StringIO
import pandas as pd
# Stands in for orders.csv, so this snippet runs on its own.
RAW = """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
"""
orders = pd.read_csv(StringIO(RAW))
north = orders[orders["branch"] == "north"]
orders["north_qty"] = north["quantity"]
print(orders["north_qty"].isna().sum(), orders["north_qty"].dtype)- A0 int64
- B4 int64
- C4 float64
- D0 float64
লক্ষ্য হলো orders-এ একটি revenue কলাম যোগ করা। কী ছাপা হবে?
from io import StringIO
import pandas as pd
# Stands in for orders.csv, so this snippet runs on its own.
RAW = """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
"""
orders = pd.read_csv(StringIO(RAW))
orders.assign(revenue=orders["quantity"] * orders["price"])
print("revenue" in orders.columns)- A`True`
- B`False` — `assign` একটি নতুন টেবিল ফেরত দেয় এবং `orders`-কে অপরিবর্তিত রাখে, আর সেই নতুন টেবিলটি কোথাও সংরক্ষণ করা হয়নি
- C`KeyError: 'revenue'` ত্রুটি
- D`ChainedAssignmentError` ত্রুটি
আপনার এমন একটি কলাম দরকার যা প্রতিটি সারিতে সেই অর্ডারের তার নিজস্ব ব্রাঞ্চের মোট কোয়ান্টিটির অনুপাত (share) ধারণ করবে। কোন লাইনটি প্রতি সারির জন্য একটি করে মান প্রদান করবে?
- Aorders["quantity"] / orders.groupby("branch")["quantity"].sum()
- Borders["quantity"] / orders["quantity"].sum()
- Corders.groupby("branch")["quantity"].sum() / orders["quantity"]
- Dorders["quantity"] / orders.groupby("branch")["quantity"].transform("sum")
উত্তর দিতে অ্যাকাউন্ট লাগবে
উত্তর মিলিয়ে দেখতে সাইন ইন করুন
প্রশ্নগুলো উপরে আছে, আর মাথায় মাথায় উত্তর ভেবে নেওয়াই আসল কাজ। সঠিক উত্তর, ব্যাখ্যা আর তিন ধাপের ইঙ্গিত দেখতে সাইন ইন করুন।
নিজে করুন
একই orders.csv ব্যবহার করে enrich_task.py লিখুন যা দোকানের মালিককে একটি ডিসকাউন্ট রিপোর্ট দেবে। প্রথমে পরিকল্পনা করুন — নতুন কলামগুলোর তালিকা লিখুন এবং ০ নম্বর সারি হাতে হিসাব করুন — তারপর ক্রমানুসারে এই কলামগুলো যোগ করুন:
revenue=quantity×pricediscount= যদিquantity১০ বা তার বেশি হয় তবেrevenue-এর ১০%, অন্যথায় ০net=revenue−discountmanager— এই অধ্যায়ের ডিকশনারি ব্যবহার করে branch থেকেband—netথেকে: ৩০০ পর্যন্ত"low", ৩০০-এর ওপর থেকে ৮০০ পর্যন্ত"mid", ৮০০-এর বেশি হলে"high"weekday—dateথেকেcategory_share— প্রতিটি অর্ডারেরnetতার নিজস্ব ক্যাটাগরির মোটnet-এর কত অংশ, ২ দশমিক স্থান পর্যন্ত রাউন্ড করা
আগে ও পরের shape প্রিন্ট করুন, তারপর net অনুযায়ী সর্বোচ্চ থেকে সর্বনিম্ন সাজিয়ে order_id, product, quantity, net, band, weekday এবং category_share প্রিন্ট করুন।
আপনার প্রোগ্রামটি তখন সঠিক হবে যখন:
- এটি নিচে দেখানো হুবহু আউটপুট প্রিন্ট করবে।
- কোথাও কোনো
forলুপ বাapplyথাকবে না — প্রতিটি কলাম পুরো কলামের ওপর একবারে হিসাব করে তৈরি হতে হবে। - ডিকশনারি থেকে কোনো ব্রাঞ্চ বাদ পড়লে প্রোগ্রাম
NaNনিয়ে চলার বদলেAssertionErrorদিয়ে থেমে যাবে। - শুরুর ও শেষের সারির সংখ্যা ৮ থাকবে।
প্রত্যাশিত আউটপুট:
before: (8, 7)
after: (8, 14)
order_id product quantity net band weekday category_share
2 1003 bag 2 1700.0 high Saturday 0.44
5 1006 bag 1 850.0 high Sunday 0.22
3 1004 bottle 7 840.0 high Saturday 0.22
7 1008 bottle 4 480.0 mid Monday 0.12
1 1002 notebook 5 300.0 low Friday 0.32
6 1007 pen 20 270.0 low Monday 0.28
4 1005 eraser 30 216.0 low Sunday 0.23
0 1001 pen 12 162.0 low Friday 0.17চালানোর আগে অনুমান করুন: কোন অর্ডারের net সবচেয়ে বেশি হবে, এবং ৩০ পরিমাণের ইরেজার অর্ডারটি কোন ব্যান্ডে (band) পড়বে? তারপর আউটপুটের সাথে মিলিয়ে নিন।
সমাধান
পরিকল্পনা। হাতে ০ নম্বর সারির হিসাব: কলমের অর্ডার, 12 × 15.0 = 180.0; পরিমাণ ১২, তাই ডিসকাউন্ট 18.0 এবং net হলো 162.0; ১৬২ হলো সর্বোচ্চ ৩০০, তাই low। ইরেজারের অর্ডার: 30 × 8.0 = 240.0, ডিসকাউন্ট 24.0, net হলো 216.0 — এটিও low, যদিও পরিমাণের দিক থেকে এটি সবচেয়ে বড় অর্ডার। সবচেয়ে বেশি net হওয়ার কথা ব্যাগের অর্ডারের (পরিমাণ ২): 1700.0, কোনো ডিসকাউন্ট নেই।
revenueএবংnet— কলামের পাটিগণিত: সাধারণ গুণ ও বিয়োগ।discount—np.where: প্রতি সারির জন্য দুটি মানের একটি।manager—.map(dict), তার সাথেisna().sum(): ডিকশনারি লুকআপ ও যাচাই।band—pd.cutযার প্রান্তবিন্দু[0, 300, 800, inf]: রেঞ্জ। প্রতিটি রেঞ্জ তার ডান প্রান্ত অন্তর্ভুক্ত করে, তাই ঠিক ৩০০ হলোlowএবং ঠিক ৮০০ হলোmid।weekday—pd.to_datetime, তারপর.dt.day_name(): তারিখের অংশের জন্য আসল তারিখ দরকার।category_share—groupby("category")["net"].transform("sum"): প্রতি সারির জন্য একটি মান, তার গ্রুপের সাথে তুলনা।
enrich_task.py:
import numpy as np
import pandas as pd
MANAGER_OF = {
"north": "Asha",
"south": "Omar",
"east": "Lina",
}
orders = pd.read_csv("orders.csv")
print("before:", orders.shape)
orders["revenue"] = orders["quantity"] * orders["price"]
orders["discount"] = np.where(orders["quantity"] >= 10, orders["revenue"] * 0.10, 0.0)
orders["net"] = orders["revenue"] - orders["discount"]
orders["manager"] = orders["branch"].map(MANAGER_OF)
assert orders["manager"].isna().sum() == 0
orders["band"] = pd.cut(
orders["net"],
bins=[0, 300, 800, np.inf],
labels=["low", "mid", "high"],
)
orders["date"] = pd.to_datetime(orders["date"])
orders["weekday"] = orders["date"].dt.day_name()
category_total = orders.groupby("category")["net"].transform("sum")
orders["category_share"] = (orders["net"] / category_total).round(2)
print("after: ", orders.shape)
print()
columns = ["order_id", "product", "quantity", "net", "band", "weekday", "category_share"]
print(orders[columns].sort_values("net", ascending=False))before: (8, 7)
after: (8, 14)
order_id product quantity net band weekday category_share
2 1003 bag 2 1700.0 high Saturday 0.44
5 1006 bag 1 850.0 high Sunday 0.22
3 1004 bottle 7 840.0 high Saturday 0.22
7 1008 bottle 4 480.0 mid Monday 0.12
1 1002 notebook 5 300.0 low Friday 0.32
6 1007 pen 20 270.0 low Monday 0.28
4 1005 eraser 30 216.0 low Sunday 0.23
0 1001 pen 12 162.0 low Friday 0.17পরিকল্পনার সাথে মিলিয়ে পড়া:
- Shape ৭টি কলাম থেকে ১৪টি হয়েছে এবং ৮টি সারিতেই রয়ে গেছে: সাতটি নতুন কলাম, কোনো সারি বাড়েনি বা কমেনি।
- ০ নম্বর সারির (অর্ডার 1001)
netএসেছে162.0এবং ব্যান্ডlow— যা আমাদের হাতে হিসাব করা মানের সাথে মিলেছে। - সর্বোচ্চ
netহলো অর্ডার 1003 (ব্যাগ),1700.0, যেমনটি অনুমান করা হয়েছিল। ইরেজার অর্ডার 1005 হলোlowব্যান্ডে216.0: সবচেয়ে বেশি পণ্য, কিন্তু কম দামি। np.whereতার "true" মান হিসেবে একটি কলাম নিয়েছে —orders["revenue"] * 0.10— ফলে প্রতিটি সারি নিজস্ব ১০% পেয়েছে, কোনো একক সংখ্যা নয়।np.where-এর উভয় শাখাই কলাম অথবা একক মান হতে পারে।- ব্যান্ডের প্রান্তবিন্দু স্পষ্ট। নোটবুক অর্ডারের
netঠিক300.0এবং তাlowহয়েছে, কারণ(0, 300]ডান প্রান্ত ৩০০-কে অন্তর্ভুক্ত করে। দোকানের মালিক যদি বুঝিয়ে থাকেন "৩০০ এবং তার বেশি হলো mid", তবে প্রান্তগুলো পরিবর্তন করতে হবে — এটি একটি সিদ্ধান্ত, নিছক খুঁটিনাটি নয়। - প্রতিটি ক্যাটাগরির ভেতরের অনুপাত যোগ করলে ১ হয়। Accessories:
0.44 + 0.22 + 0.22 + 0.12 = 1.00। Stationery:0.32 + 0.28 + 0.23 + 0.17 = 1.00। কোনো ক্যাটাগরির অনুপাত যোগ করে ১ না হলে বুঝতে হবেtransform-এ ভুল কলাম দিয়ে গ্রুপ করা হয়েছে।
তারপর ইচ্ছে করে কোডটি ভাঙুন, প্রতিবার একবার করে: ডিকশনারি থেকে "east" সরিয়ে দিন এবং দেখুন কীভাবে assert প্রোগ্রামটি থামিয়ে দেয়; bins শূন্যের বদলে ২০০ থেকে শুরু করুন এবং দেখুন band-এর কোন কোন সারিতে NaN আসে এবং কেন।
ধাপ ৬ / ৬
কঠিন করা — অধ্যায়ের কুইজ
সহজ থেকে কঠিন — দশটি প্রশ্ন, শেষেরগুলো ইচ্ছে করেই কঠিন।
সাইন ইন করে কুইজ দিন