11 সেপ্টেম্বর, 2026 · পুনরুৎপাদনযোগ্যতা

একই কোড, একই ডেটা, দুটি আলাদা Sharpe: আপনার ব্যাকটেস্টে যে nondeterminism নিরীক্ষা দরকার

একই কোড, একই ডেটা, দুটি আলাদা Sharpe: আপনার ব্যাকটেস্টে যে nondeterminism নিরীক্ষা দরকার

একই commit, একই parquet ফাইল, একই মেশিন—এক ঘণ্টার ব্যবধানে দুটি রান। Sharpe 1.34 ও Sharpe 1.19। মোট রিটার্ন 41.2% ও 37.8%। ট্রেডের সংখ্যা 1,418 ও 1,421। আর আলাদা হয়ে যাওয়ার আগে দুটি equity curve টানা 11,205টি বারে মুদ্রিত প্রতিটি দশমিক পর্যন্ত এক ছিল।

0.15একই ইনপুটে Sharpe-এর ব্যবধান
3 / 1,418যে ট্রেডগুলো আলাদা ছিল
11,205আলাদা হওয়ার আগে অভিন্ন বার

প্রথম তিনটি সংখ্যা বিরক্তিকর। শেষেরটি আকর্ষণীয়, কারণ এটি বলে যে গোটা রানজুড়ে ভাসমান বিন্দুর হিসাবের ছোটখাটো ভুল ছড়িয়ে পড়েনি। নির্দিষ্ট একটি বারে বিচ্ছিন্ন কোনো ঘটনা ঘটেছিল, তারপর তার প্রভাব ক্রমে জমেছে। এই লেখায় সেই বারটি কীভাবে খুঁজে পেলাম, আর ওই 0.15 ব্যবধান আপনার চালানো প্রতিটি parameter sweep-কে কীভাবে প্রভাবিত করে, তা বলছি।

কৌশলটি হলো 30টি সর্বোচ্চ ভলিউমের USDⓈ-M perp-এ cross-sectional momentum: প্রতি 4 ঘণ্টায় rebalance, 12 ঘণ্টার রিটার্নে শীর্ষ পাঁচটিতে long, নিচের পাঁচটিতে short, 18 মাসের ইতিহাস, আর প্রতি লেগে fee ও funding ধরা হয়েছে।

যে বারটিতে রান দুটি আলাদা হলো, সেটি খুঁজে বের করা

প্রতি বারের equity পূর্ণ নির্ভুলতায় লগ করলে কাজটা প্রায় চার মিনিটের। দুটি রানই (bar_index, equity, open_positions_hash)-এর CSV-তে নামিয়ে, দুটো লোড করে, যে প্রথম index-এ অমিল দেখা যায় সেটি খুঁজুন। অন্য একটি bug-এর জন্য ওই script আগে থেকেই লিখেছিলাম; নইলে এটাতেই আমার একটা সকাল চলে যেত।

বার 11,206, 2025-03-14T08:00 UTC। আগের বারে 13টি significant figure পর্যন্ত equity অভিন্ন। 11,206-এ position hash আলাদা: রান A-তে SOL long, রান B-তে AVAX long। দিক এক, notional এক, symbol আলাদা। এরপরের সবকিছুই এর পরিণতি।

তাই ওই বারের ranking input দুটি রানেই ছাপিয়ে দেখলাম। সেগুলো অভিন্ন ছিল। byte-for-byte এক, একই 30টি symbol, একই 30টি score। দুটি score ছিল 0.0। একেবারে শূন্য—কারণ দুটি symbol-ই lookback window-এর মধ্যে শূন্য ট্রেডসহ একটি 4-hour bar দেখিয়েছিল। ফলে close-over-close হয়েছে এক, আর log হয়েছে শূন্য। rounding করে শূন্য হয়নি। একেবারে শূন্য।

দুটি symbol পঞ্চম rank-এ টাই করেছিল। বাছাইয়ে শীর্ষ পাঁচটিকে নেওয়া হয়েছিল। দুটির মধ্যে কোনটি ঢুকবে, তা নির্ভর করছিল sort সেগুলোকে কোন ক্রমে রেখেছে তার ওপর; আর sort-টি আমার ধারণামতো কাজ করছিল না।

টাই, unstable sort, আর যে বিষয়টি দুই বছর ধরে উপেক্ষা করছিলাম

ranking চলেছিল একটি grouped aggregation-এর মধ্য দিয়ে। এতে ডেটা আসছিল symbol-গুলোর একটি set-এর ওপর চালানো dict comprehension থেকে, যা প্রতি বারে async fetch-এর মাধ্যমে আবার তৈরি হতো। hash seed বদলালে set-এর iteration order বদলে যায়, আর PYTHONHASHSEED নির্দিষ্ট করে না দিলে Python প্রতিটি process-এর জন্য string hash seed এলোমেলোভাবে ঠিক করে। ফলে sort-এর আগের row order রানভেদে আলাদা ছিল; আর tied key-তে non-stable sort থাকায় tie-ও ভিন্নভাবে ভাঙছিল।

এ ধরনের tie কোনো বিরল ব্যতিক্রম নয়। এগুলো কাঠামোগত। কোনো feature saturate বা clip হলেই ঠিক সমান মান তৈরি হয়: zero-volume bar-এ রিটার্ন একেবারে শূন্য, clipped z-score আটকে যায় ±3.0-এ, কমসংখ্যক স্বতন্ত্র মানের rank-transform-এ ডজন ডজন tie তৈরি হয়, আর boolean filter-এ শর্ত পেরোনো সবকিছুর score হয় 1.0। 4-hour bar-এ 18 মাসের এই রানে selection boundary-তে tie ছিল 47টি বারে। এর মধ্যে তিনটিতে নির্বাচিত basket বদলেছিল। বাকি tie-গুলো ছিল এমন দুটি নামের মধ্যে, যাদের দুটিই আগে থেকে অন্তর্ভুক্ত ছিল, অথবা দুটিই বাদ পড়েছিল।

প্রায় এক দিন আমি নিশ্চিত ছিলাম data loader-এর nondeterminism-এর কারণেই এটা ঘটেছে, কারণ সেটাই ছিল চমকপ্রদ উত্তর। তা ছিল না। কখনোই তা হয় না। কারণ ছিল একটি set, একটি tie, আর sort-এর স্থিতিশীলতা সম্পর্কে এমন একটি ধারণা—যা কেউ লিখে রাখেনি।

তিনটি ট্রেডে Sharpe 0.15 বদলায় কেন

এই অংশেই অনেকে আপত্তি তোলেন। উত্তরটা হলো, equity-র ভগ্নাংশ ধরে position size নির্ধারণ করলে ব্যাকটেস্ট path-dependent system হয়ে যায়। প্রতি লেগে বর্তমান equity-র 8% ধরে size নিলে, বার n-এ equity-র পার্থক্য মানে বার n থেকে পরের প্রতিটি notional-এ পার্থক্য।

প্রথম divergence-এর নিজস্ব ক্ষতি সামান্যই ছিল। রান B-র AVAX লেগ নয় ঘণ্টায় 2.1% হারায়; রান A-র SOL লেগ 0.4% লাভ করে। ওই ট্রেডের পর equity gap: 0.21%। তুচ্ছ। কিন্তু এরপর দুটি রান আর একই কৌশল থাকে না। তাদের position size সামান্য আলাদা থাকে, তাই funding-এর হিসাবও সামান্য আলাদা হয়; আর পরে boundary-র দুটি tie আবার ভিন্নভাবে ভাঙে, কারণ তখন সেগুলোর score সামান্য ভিন্ন position থেকে আসে। এর একটি ঘটেছিল 2025-03-27-এ, ছয় দিনের একটি trend-এর ঠিক আগের দিন; ওই trend থেকে রানের মোট PnL-এর প্রায় এক-তৃতীয়াংশ এসেছিল। রান A পুরো ওঠানামায় ছিল; রান B একটি rebalance দেরিতে ঢুকেছিল।

রিটার্নের ব্যবধান: 3.4 পয়েন্ট। রিটার্নের ব্যবধান দেখে যতটা মনে হয়, Sharpe-এর ব্যবধান তার চেয়ে বড়—কারণ রান B-র পুনর্বিন্যস্ত ট্রেডগুলো বেশি volatile সময়ের সঙ্গে মিলে গিয়েছিল। ফলে numerator কমার সময় denominator বেড়েছিল। ছোট কারণ, দুটি প্রভাববর্ধক।

আপনার position sizing যদি fixed-notional হয় এবং entry বর্তমান holding-এর ওপর নির্ভর না করে, তাহলে এ ধরনের প্রভাব থেকে আপনি অনেকটাই সুরক্ষিত। বেশির ভাগ আকর্ষণীয় কৌশলই এর কোনোটিই নয়।

যে পাঁচটি জায়গায় এই সমস্যা দেখা দেয়

উৎসলক্ষণসমাধান
নির্দিষ্ট না করা PYTHONHASHSEED এবং sort-এ set/dict-এর iteration order ব্যবহাররানভেদে tie-break বদলে যায়; নির্দিষ্ট বারে প্রথম divergence দেখা দেয়seed নির্দিষ্ট করুন; tie নির্ধারিতভাবে ভাঙতে symbol-কে স্পষ্ট secondary key হিসেবে sort করুন
tied key-তে non-stable sort (quicksort NumPy/pandas-এর default)আগের সমস্যার মতোই; seed নির্দিষ্ট করলেও থেকে যায়kind="stable", অথবা key-কে total order-এ আনুন
bootstrap, train/test shuffle, বা synthetic fill jitter-এ unseeded RNGগোটা রানে বিচ্যুতি; নির্দিষ্ট কোনো divergence point নেইপ্রতিটি component-এর জন্য একটি স্পষ্ট seed ব্যবহার করুন, run manifest-এ লগ করুন
parallel float reduction (thread count-এর ওপর summation order নির্ভর করে)শেষের কয়েকটি bit-এ পার্থক্য; threshold comparison পার না হওয়া পর্যন্ত সাধারণত ক্ষতিকর নয়research run-এ thread count নির্দিষ্ট করুন; decision boundary-তে float-এর তুলনা কখনো == দিয়ে করবেন না
নির্দিষ্ট না করা library versionআজ পুনরুৎপাদনযোগ্য, নভেম্বরে নয়data snapshot hash-এর পাশাপাশি manifest-এ lockfile hash রাখুন

চতুর্থ সারির সমস্যা মানুষ যতটা ভাবে ততটা ঘন ঘন হয় না; প্রথম সারির সমস্যায় কিন্তু নিয়মিতই ধরা খেতে হয়।

bit-reproducibility একটি হাতিয়ার, গুণ নয়

আপনি determinism চান যাতে কোডের একটি লাইন বদলালে equity curve-এ যে পার্থক্য দেখা যায়, তার কারণ ওই লাইনটিই বলে নিশ্চিত হতে পারেন। কারণটা শুধু এই। Stratmill-এ এখন প্রতিটি agent run data snapshot hash, lockfile hash আর সব seed-সহ একটি manifest লেখে। আগের curve bit-for-bit পুনরুৎপাদন করতে না পারলে rerun-টিকে কৌতূহলোদ্দীপক ঘটনা নয়, ব্যর্থ build হিসেবে ধরা হয়।

তবে একবার পুনরুৎপাদন করতে পারলে, ইচ্ছে করেই সেটি ভিন্নভাবে চালান। 64টি seed দিয়ে কাজটি 64 বার চালিয়ে ফলের বিস্তার দেখুন:

jitter band। একই কৌশল, একই ডেটা, tie-break ও fill order-এর 64টি seeded permutation। Sharpe p5 1.12, median 1.27, p95 1.41। band-এর প্রস্থ 0.29।

এবার parameter sweep-এ ফিরে যান। সেরা config-এর score 1.46। 96টির মধ্যে 40তম config-এর score 1.31। তাদের ব্যবধান 0.15, যা band-এর অর্ধেক। sweep ওই দুটি config-কে ক্রমে সাজায়নি। তাদের প্রত্যেকের distribution থেকে একটি করে ফল নিয়ে সেই ফলগুলো সাজিয়েছে।

এই দৃষ্টিভঙ্গির বদল আমাদের বাছাইয়ের পদ্ধতিও পাল্টেছে। config-গুলোর মধ্যকার ব্যবধান কোনো একটি config-এর jitter-এর চেয়ে বড় হলেই কেবল sweep-এর ফলকে ranking বলা যায়। 1,400টি ট্রেডসহ path-dependent কৌশলে jitter সাধারণত এতটাই বড় যে leaderboard-এর ওপরের এক-তৃতীয়াংশকে টাইয়ের মতো দেখায়। এমন হলে band যা আড়াল করতে পারে না, সেটির ভিত্তিতে বেছে নিন: কম turnover, কম parameter, সংশয়ী কারও কাছেও যুক্তিসহকারে ব্যাখ্যা করতে পারবেন এমন cost assumption, অথবা আপনার সবচেয়ে কম পছন্দের walk-forward fold-এ ভালো আচরণ। এগুলো সত্যিকারের tie-breaker। Sharpe-এ 0.15 এগিয়ে থাকা তা নয়।

এর কোনো ফল বিশ্বাস করার আগে আরেকটি কাজ করা দরকার। এখনই আপনার ব্যাকটেস্ট দুবার চালান, প্রতি বারের equity diff করুন, আর জেনে নিন আপনি bit-identical দলে, নাকি 0.15 ব্যবধানের দলে। 15 মিনিটের এই পরীক্ষা বলে দেবে, আপনার গবেষণার ইতিহাসের কতটা কৌশলকে মেপেছে আর কতটা hash seed-কে।

determinismbacktestingdata engineeringoverfittingpython
শেয়ার করুনXLinkedInFacebookRedditHacker NewsWhatsAppTelegramEmail
← সব পোস্ট