এক্সপ্লেইনেবল AI (XAI) টেকনিক — LIME ও SHAP-এর ধারণা
এই পাঠে যা শিখবেন
- LIME-এর পেছনের সাধারণ ধারণা — লোকাল সারোগেট (surrogate) মডেল
- SHAP-এর পেছনের সাধারণ ধারণা — শ্যাপলি ভ্যালু দিয়ে ন্যায্য ফিচার অ্যাট্রিবিউশন
- একটি সত্যিকারের, গণনাকৃত লোকাল লিনিয়ার অ্যাপ্রক্সিমেশন ডেমো
- একটি সত্যিকারের, গণনাকৃত ছোট শ্যাপলি-ভ্যালু ডেমো
- এই টেকনিকগুলোর সীমাবদ্ধতা — এগুলো ব্যাখ্যা "তৈরি" করে, কিন্তু নিখুঁত সত্য গ্যারান্টি করে না
১ · LIME — লোকাল সারোগেট মডেলের ধারণা
LIMELIME (Local Interpretable Model-agnostic Explanations)একটি ব্ল্যাক-বক্স মডেলের আচরণ একটি নির্দিষ্ট ইনপুট পয়েন্টের আশেপাশে একটি সরল, ইন্টারপ্রিটেবল মডেল দিয়ে আনুমানিক করার একটি টেকনিক-পরিবার — সম্পূর্ণ মডেল ব্যাখ্যা করার চেষ্টা করে না, শুধু একটি নির্দিষ্ট পয়েন্টের কাছাকাছি। -এর মূল অন্তর্দৃষ্টি সহজ: একটি ব্ল্যাক-বক্স মডেল সামগ্রিকভাবে অত্যন্ত জটিল, অ-রৈখিক হতে পারে — কিন্তু কোনো একটি নির্দিষ্ট পয়েন্টের খুব কাছাকাছি এলাকায়, প্রায় যেকোনো মসৃণ ফাংশনকেই মোটামুটি একটি সরল রৈখিক (linear) ফাংশন দিয়ে আনুমানিক করা সম্ভব — ঠিক যেমন একটি বাঁকা রাস্তার খুব ছোট অংশকে প্রায় সরলরেখা মনে হয়।
বাস্তব LIME অ্যালগরিদম কাজ করে এভাবে (সংক্ষেপে, পূর্ণ গাণিতিক বিস্তারিত ছাড়া): (১) যে পয়েন্টের ব্যাখ্যা দরকার তার আশেপাশে র্যান্ডম পার্টার্বেশন (সামান্য পরিবর্তিত) স্যাম্পল তৈরি করা হয়, (২) প্রতিটি স্যাম্পলের জন্য আসল ব্ল্যাক-বক্স মডেল থেকে আউটপুট নেওয়া হয়, (৩) মূল পয়েন্টের যত কাছাকাছি একটি স্যাম্পল, তাকে তত বেশি গুরুত্ব (ওজন) দিয়ে, একটি সরল ইন্টারপ্রিটেবল মডেল (সাধারণত একটি ওয়েটেড লিনিয়ার রিগ্রেশন) ফিট করা হয়। নিচের কোড সেলে আমরা এই ধারণার একটি সরলীকৃত সংস্করণ বাস্তবায়ন করছি — নৈকট্য-ভিত্তিক ওজন ছাড়া, শুধু একটি নির্দিষ্ট ছোট পরিসরে স্যাম্পল নিয়ে সরল (আন-ওয়েটেড) least-squares ফিট করে।
# LIME-এর ধারণার একটি সরলীকৃত, সৎ ডেমো (আসল LIME লাইব্রেরির প্রতিলিপি নয়)
import random
random.seed(7)
def black_box(x1, x2):
# কাল্পনিক "ব্ল্যাক-বক্স" মডেল -- ইচ্ছাকৃতভাবে অ-রৈখিক (nonlinear)
return 2*x1 + 3*x2 + 0.4*x1*x2 - 0.1*x1**2
point_of_interest = (3.0, 2.0)
# ধাপ ১: point_of_interest-এর কাছাকাছি (একটি ছোট পরিসরে) কিছু পয়েন্ট স্যাম্পল করা
samples = []
for _ in range(60):
dx1 = random.uniform(-0.5, 0.5)
dx2 = random.uniform(-0.5, 0.5)
x1 = point_of_interest[0] + dx1
x2 = point_of_interest[1] + dx2
y = black_box(x1, x2)
samples.append((x1, x2, y))
# ধাপ ২: একটি সরল লিনিয়ার মডেল y = b0 + b1*x1 + b2*x2 ফিট করা -- normal equations দিয়ে
def build_normal_equations(samples):
XtX = [[0.0]*3 for _ in range(3)]
Xty = [0.0]*3
for x1, x2, y in samples:
row = [1.0, x1, x2]
for i in range(3):
Xty[i] += row[i] * y
for j in range(3):
XtX[i][j] += row[i] * row[j]
return XtX, Xty
def solve_3x3(A, b):
# গাউসিয়ান এলিমিনেশন (partial pivoting) দিয়ে ৩x৩ লিনিয়ার সিস্টেম সমাধান করা
A = [row[:] for row in A]
b = b[:]
n = 3
for col in range(n):
pivot_row = max(range(col, n), key=lambda r: abs(A[r][col]))
A[col], A[pivot_row] = A[pivot_row], A[col]
b[col], b[pivot_row] = b[pivot_row], b[col]
pivot = A[col][col]
for r in range(col+1, n):
factor = A[r][col] / pivot
for c in range(col, n):
A[r][c] -= factor * A[col][c]
b[r] -= factor * b[col]
x = [0.0]*n
for i in reversed(range(n)):
s = b[i] - sum(A[i][j]*x[j] for j in range(i+1, n))
x[i] = s / A[i][i]
return x
XtX, Xty = build_normal_equations(samples)
b0, b1, b2 = solve_3x3(XtX, Xty)
def local_linear_approx(x1, x2):
return b0 + b1*x1 + b2*x2
print(f"ফিট করা লোকাল লিনিয়ার মডেল: y ≈ {b0:.3f} + {b1:.3f}*x1 + {b2:.3f}*x2\n")
actual = black_box(*point_of_interest)
approx = local_linear_approx(*point_of_interest)
print(f"পয়েন্ট অফ ইন্টারেস্ট: {point_of_interest}")
print(f"ব্ল্যাক-বক্সের প্রকৃত আউটপুট: {actual:.3f}")
print(f"লোকাল লিনিয়ার আনুমানিক (LIME-স্টাইল): {approx:.3f}")
print(f"পার্থক্য: {abs(actual-approx):.4f}\n")
print("পয়েন্ট অফ ইন্টারেস্টের কাছাকাছি আরও কয়েকটি টেস্ট পয়েন্টে তুলনা:")
random.seed(99)
total_err = 0.0
n_test = 5
for _ in range(n_test):
tx1 = point_of_interest[0] + random.uniform(-0.3, 0.3)
tx2 = point_of_interest[1] + random.uniform(-0.3, 0.3)
a = black_box(tx1, tx2)
p = local_linear_approx(tx1, tx2)
err = abs(a - p)
total_err += err
print(f" ({tx1:.2f}, {tx2:.2f}) -- প্রকৃত: {a:.3f}, আনুমানিক: {p:.3f}, পার্থক্য: {err:.4f}")
print(f"\nগড় absolute error (কাছাকাছি ৫টি টেস্ট পয়েন্টে): {total_err/n_test:.4f}")
black_box() ফাংশনটি নিজেই অ-রৈখিক (এতে x1*x2 ও x1**2
পদ আছে), তবুও পয়েন্ট অফ ইন্টারেস্টের কাছাকাছি ছোট পরিসরে (±0.5) ফিট করা সরল লিনিয়ার মডেলটি
গড় error যথেষ্ট ছোট রাখে। এটাই LIME-এর মূল অন্তর্দৃষ্টি — "সম্পূর্ণ মডেল ব্যাখ্যা করো না, শুধু এই একটি পয়েন্টের
আশেপাশের আচরণ ব্যাখ্যা করো।" এই ডেমোটি real LIME লাইব্রেরির নয় (আসল LIME দূরত্ব-ভিত্তিক ওজন, ফিচার
স্পার্সিফিকেশন, এবং আরও অনেক প্র্যাকটিক্যাল বিবরণ ব্যবহার করে) — এটি শুধু তার পেছনের মূল ধারণার একটি সৎ,
সরলীকৃত চিত্র।
২ · SHAP — শ্যাপলি ভ্যালু দিয়ে ন্যায্য ফিচার অ্যাট্রিবিউশন
SHAPSHAP (SHapley Additive exPlanations)গেম-থিওরির শ্যাপলি ভ্যালু ব্যবহার করে একটি মডেলের প্রেডিকশনকে প্রতিটি ইনপুট ফিচারের মধ্যে ন্যায্যভাবে ভাগ করে দেওয়ার একটি টেকনিক-পরিবার — প্রতিটি ফিচার প্রেডিকশনে ঠিক কতটা "অবদান" রাখলো তা সংখ্যায় প্রকাশ করে। -এর ধারণাটি এসেছে সমবায়ী গেম থিওরি (cooperative game theory) থেকে — মূল প্রশ্নটি ছিল: যদি কয়েকজন খেলোয়াড় একসাথে দল বেঁধে একটি পুরস্কার জেতে, তাহলে সেই পুরস্কার তাদের মধ্যে ন্যায্যভাবে কীভাবে ভাগ করা উচিত, যেখানে প্রতিটি খেলোয়াড়ের প্রকৃত অবদান ভিন্ন হতে পারে? শ্যাপলি ভ্যালু এই প্রশ্নের একটি গাণিতিকভাবে সুনির্দিষ্ট, ন্যায্যতার কয়েকটি স্বতঃসিদ্ধ (axiom) সন্তুষ্টকারী সমাধান দেয়।
SHAP-এ "খেলোয়াড়" হলো একেকটি ইনপুট ফিচার, আর "পুরস্কার" হলো মডেলের প্রেডিকশন (একটি বেসলাইনের তুলনায়)। একটি
ফিচারের শ্যাপলি ভ্যালু বের করতে, ফিচারগুলোকে সব সম্ভাব্য ক্রমে একে একে "যোগ" করার কল্পনা করা হয়, এবং প্রতিটি
ক্রমে একটি ফিচার যোগ হওয়ার সাথে সাথে প্রেডিকশন কতটা বদলায় (তার "প্রান্তিক অবদান" বা marginal contribution)
তা মাপা হয় — তারপর সব সম্ভাব্য ক্রম জুড়ে এই প্রান্তিক অবদানের গড় নেওয়া হয়। নিচের কোড সেলে মাত্র ২টি ফিচারের
একটি ছোট, সম্পূর্ণ গণনাযোগ্য উদাহরণে এটি হুবহু বাস্তবায়ন করা হয়েছে (২টি ফিচারের ক্ষেত্রে মাত্র
2! = 2টি সম্ভাব্য ক্রম থাকে, তাই পুরো গণনা সম্ভব)।
# SHAP-এর ধারণার একটি ছোট, সম্পূর্ণ গণনাযোগ্য শ্যাপলি-ভ্যালু ডেমো (২টি ফিচার)
import itertools
def toy_function(x1, x2):
return 2*x1 + 3*x2 + x1*x2 # ইচ্ছাকৃতভাবে একটি ইন্টারঅ্যাকশন-টার্ম (x1*x2) রাখা হয়েছে
baseline = (0, 0) # "কোনো ফিচার উপস্থিত নয়" -- বেসলাইন রেফারেন্স পয়েন্ট
instance = (4, 5) # যে নির্দিষ্ট পয়েন্টের ব্যাখ্যা চাই
orderings = list(itertools.permutations([1, 2])) # ২টি ফিচারের সব সম্ভাব্য ক্রম -- ২! = ২টি
contributions = {1: [], 2: []}
for order in orderings:
current = list(baseline)
prev_value = toy_function(*current)
for feat in order:
if feat == 1:
current[0] = instance[0]
else:
current[1] = instance[1]
new_value = toy_function(*current)
contributions[feat].append(new_value - prev_value)
prev_value = new_value
shap_x1 = sum(contributions[1]) / len(contributions[1])
shap_x2 = sum(contributions[2]) / len(contributions[2])
print(f"বেসলাইন আউটপুট f{baseline}: {toy_function(*baseline)}")
print(f"ইনস্ট্যান্স আউটপুট f{instance}: {toy_function(*instance)}\n")
print("প্রতিটি সম্ভাব্য ক্রমে x1-এর প্রান্তিক অবদান:", contributions[1])
print("প্রতিটি সম্ভাব্য ক্রমে x2-এর প্রান্তিক অবদান:", contributions[2])
print(f"\nশ্যাপলি ভ্যালু (x1): {shap_x1:.2f}")
print(f"শ্যাপলি ভ্যালু (x2): {shap_x2:.2f}")
total_shap = shap_x1 + shap_x2
total_diff = toy_function(*instance) - toy_function(*baseline)
print(f"\nশ্যাপলি ভ্যালুর যোগফল: {total_shap:.2f}")
print(f"ইনস্ট্যান্স আউটপুট - বেসলাইন আউটপুট: {total_diff:.2f}")
print("(এই দুটো সমান হওয়াটাই SHAP-এর একটি মূল বৈশিষ্ট্য -- efficiency/সংযোজনযোগ্যতা: সব ফিচারের অবদানের যোগফল ঠিক মোট পার্থক্যের সমান।)")
toy_function()-এ x1*x2 একটি ইন্টারঅ্যাকশন-টার্ম, তাই "x1-এর একার
অবদান কত" প্রশ্নটির কোনো একক সরল উত্তর নেই — ক্রম অনুযায়ী উত্তর বদলায় (যেমন প্রিন্ট আউটপুটে দেখা যাচ্ছে,
x1-এর প্রান্তিক অবদান দুই ক্রমে ভিন্ন)। শ্যাপলি ভ্যালু এই সমস্যার সমাধান দেয় সব সম্ভাব্য ক্রমের গড় নিয়ে —
এবং গুরুত্বপূর্ণভাবে, দুই ফিচারের শ্যাপলি ভ্যালুর যোগফল ঠিক মোট প্রেডিকশন-পার্থক্যের সমান হয় (efficiency
প্রপার্টি), যা কোডের শেষ দুই লাইনে সত্যিই মিলিয়ে দেখানো হয়েছে। বাস্তব SHAP লাইব্রেরি একই মূল গাণিতিক ধারণা
ব্যবহার করে, কিন্তু ফিচার-সংখ্যা বেশি হলে সব ক্রম গণনা করা কম্পিউটেশনালি ব্যয়বহুল হয়ে যায় বলে বিভিন্ন
স্মার্ট আনুমানিক (approximation) কৌশল ব্যবহার করে — এই ডেমোটি শুধু মূল ধারণাটির একটি সৎ, ছোট-স্কেল চিত্র।
LIME ও SHAP উভয়ই ব্ল্যাক-বক্স মডেলকে "খুলে" না দেখেই তার সিদ্ধান্ত সম্পর্কে একটি আংশিক, সহায়ক ব্যাখ্যা তৈরি করে — কিন্তু কোনোটিই মডেলের সম্পূর্ণ, নিখুঁত সত্য ব্যাখ্যা নয়। এগুলো আনুমানিক (approximation), এবং সেই আনুমানিকতার নিজস্ব সীমাবদ্ধতা আছে — LIME-এর ব্যাখ্যা কোন পয়েন্টের কতটা কাছাকাছি স্যাম্পল করা হয়েছে তার উপর নির্ভরশীল, আর SHAP-এর ব্যাখ্যা ফিচারগুলোর মধ্যে জটিল নির্ভরতা থাকলে বিভ্রান্তিকর হতে পারে। তবুও, এগুলো L18-এর সম্পূর্ণ অস্বচ্ছতার তুলনায় একটি গুরুত্বপূর্ণ ধাপ এগিয়ে — একেবারে কিছু না জানার চেয়ে একটি সৎ আনুমানিক ব্যাখ্যা পাওয়া ভালো, যতক্ষণ তার সীমাবদ্ধতা স্বীকার করা হয়।
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিজে কিছুক্ষণ ভাবুন — তারপর "→ উত্তর" চাপুন।
প্র ০১
প্রথম কোড সেলে যদি স্যাম্পলিং রেঞ্জ ±0.5-এর বদলে ±5.0 করা হতো, তাহলে
গড় error-এর কী হতো বলে আপনার ধারণা, এবং কেন?
গড় error বেড়ে যেত। কারণ black_box() ফাংশনটি অ-রৈখিক — একটি নির্দিষ্ট পয়েন্টের যত দূরে
যাওয়া হয়, ততই সেই পয়েন্টের কাছাকাছি ফিট করা লিনিয়ার আনুমানিকটি প্রকৃত (অ-রৈখিক) মানের থেকে দূরে সরে
যায়। এটাই LIME-এর একটি গুরুত্বপূর্ণ সীমাবদ্ধতা — "লোকাল" শব্দটি গুরুত্বপূর্ণ, ব্যাখ্যাটি শুধু একটি ছোট
আশেপাশের এলাকায় নির্ভরযোগ্য, সম্পূর্ণ মডেলের জন্য নয়।
প্র ০২
দ্বিতীয় কোড সেলে যদি toy_function-এ x1*x2 ইন্টারঅ্যাকশন-টার্মটি না থাকতো
(শুধু 2*x1 + 3*x2), তাহলে দুই ক্রমে x1-এর প্রান্তিক অবদান কি একই হতো, নাকি ভিন্ন?
একই হতো। ইন্টারঅ্যাকশন-টার্ম ছাড়া ফাংশনটি সম্পূর্ণ additive (প্রতিটি ফিচারের প্রভাব স্বাধীন), তাই x1
যোগ করার প্রান্তিক অবদান সবসময় ঠিক 2*4 = 8 হবে, x2 আগে যোগ হোক বা পরে — কোনো ক্রম-নির্ভরতা
থাকবে না। ইন্টারঅ্যাকশন-টার্ম থাকলেই ক্রম-নির্ভরতা তৈরি হয়, এবং তখনই শ্যাপলি ভ্যালুর "সব ক্রমের গড় নাও"
পদ্ধতিটি সত্যিকারের অর্থ বহন করে।
প্র ০৩ একজন ব্যাংক কর্মকর্তা বলছেন, "SHAP আমাদের মডেলকে সম্পূর্ণ স্বচ্ছ করে দিয়েছে, তাই এখন আমাদের আর L18-এর ইন্টারপ্রিটেবল মডেলের কথা ভাবার দরকার নেই।" এই দাবিতে কী সমস্যা থাকতে পারে?
SHAP মডেলের সিদ্ধান্তকে সম্পূর্ণ "স্বচ্ছ" করে না — এটি একটি আনুমানিক ব্যাখ্যা তৈরি করে, যা মূল মডেলের প্রকৃত (এবং হয়তো আরও জটিল) যুক্তির একটি সরলীকৃত প্রক্সি মাত্র। ইন্টারপ্রিটেবল মডেলে (L18) প্রতিটি রুল সরাসরি, নিশ্চিতভাবে যাচাইযোগ্য; SHAP-এর ব্যাখ্যায় সবসময় কিছুটা আনুমানিকতা ও অনিশ্চয়তা থাকে, বিশেষ করে ফিচারগুলোর মধ্যে জটিল নির্ভরতা থাকলে। উচ্চ-ঝুঁকির প্রেক্ষাপটে (স্বাস্থ্যসেবা, ফৌজদারি ন্যায়বিচার) এই পার্থক্যটি গুরুত্বপূর্ণ — SHAP ব্যাখ্যাকে "সহায়ক তথ্য" হিসেবে বিবেচনা করা উচিত, "নিখুঁত সত্য" হিসেবে নয়।
অনুশীলন
-
চিন্তা করুন: প্রথম কোড সেলে
point_of_interest-কে(3.0, 2.0)থেকে(0.0, 0.0)-এ পরিবর্তন করলে ফিট করা লিনিয়ার মডেলের কোয়েফিসিয়েন্টb1ওb2-এর মান কি একই থাকবে, নাকি বদলাবে?বদলাবে — কারণ
black_box()অ-রৈখিক, তাই তার "লোকাল ঢাল" (local slope) প্রতিটি পয়েন্টে ভিন্ন হতে পারে।x1**2ওx1*x2পদের কারণে, ফাংশনের বক্রতা বিভিন্ন অঞ্চলে ভিন্ন — তাই(0,0)-এর কাছাকাছি ফিট করা লিনিয়ার আনুমানিকের কোয়েফিসিয়েন্ট(3,2)-এর কাছাকাছি ফিট করা কোয়েফিসিয়েন্ট থেকে ভিন্ন হবে। এটাই দেখায় কেন LIME-এর ব্যাখ্যা সবসময় একটি নির্দিষ্ট পয়েন্টের জন্য নির্দিষ্ট, গোটা মডেলের জন্য সার্বজনীন নয়। -
পরীক্ষা করুন: প্রথম কোড সেলে
point_of_interest = (0.0, 0.0)করে Run চেপে নতুন কোয়েফিসিয়েন্ট প্রিন্ট আউটপুটে দেখুন এবং আপনার অনুমানের সাথে মিলিয়ে নিন।নতুন রান-এ ফিট করা কোয়েফিসিয়েন্ট আগেরটির থেকে ভিন্ন হবে (উভয়ই মোটামুটি
b1≈2,b2≈3-এর কাছাকাছি থাকবে যেহেতু সেগুলো ফাংশনের প্রধান রৈখিক পদ, কিন্তু ইন্টারঅ্যাকশন ও বর্গ-পদের প্রভাবে ছোট পার্থক্য দেখা যাবে) — এটি নিশ্চিত করে যে ফিটটি সত্যিই প্রতিবার নতুন করে গণনা হচ্ছে, কোনো হার্ডকোড করা মান প্রিন্ট হচ্ছে না।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- কোর্সের সম্পূর্ণ সিলেবাস দেখুন ৫৭টি পাঠ নৈতিক ফ্রেমওয়ার্ক, বায়াস-ফেয়ারনেস, প্রাইভেসি, ট্রান্সপারেন্সি, AI অ্যালাইনমেন্ট, জেনারেটিভ AI/LLM এথিক্স, সামাজিক প্রভাব, গভর্নেন্স ও রেগুলেশন, সেক্টর-স্পেসিফিক এথিক্স ও এক্সিস্টেনশিয়াল রিস্ক বিতর্ক — বাকি পাঠগুলো দেখুন।
- Ethics in Computing & AI Safety কোর্স সহোদর কোর্স সাধারণ কম্পিউটিং এথিক্স, প্রফেশনাল এথিক্স ও সেফটি-ক্রিটিক্যাল কেস স্টাডির একটি বিস্তৃত সার্ভে — এই কোর্স সম্পূর্ণভাবে AI-তে ফোকাস করে গভীরে যায়।
- Machine Learning কোর্স ফাউন্ডেশন রিগ্রেশন ও লিনিয়ার মডেল ফিটিংয়ের পূর্ণ কারিগরি ভিত্তি — এখানে যা প্রয়োজন সেটুকু ব্যাখ্যা করা হয়েছে।