فاز 2 · درس 21

ذخیره‌ساز ویژگی (Feature Store) و رفع انحراف آموزش و سرویس‌دهی

نوع
ساختنی
زمان
حدود 100 دقیقه
زبان‌ها
Python
نمای نمادین از یک ساعت شنی و یک خط لوله‌ی داده‌ی دوگانه که گذشته و حال را هم‌گام می‌کنند
فهرست مطالب
  1. اهداف یادگیری
  2. صورت مسئله
  3. مفاهیم پایه
  4. دو خواننده برای یک ویژگی
  5. موجودیت‌ها، برچسب‌های زمانی و TTL
  6. پیوندهای مبتنی بر نقطه در زمان
  7. دلایل انحراف آموزش و سرویس‌دهی
  8. رجیستری ویژگی
  9. بازپرها
  10. شناسایی انحراف
  11. پیاده‌سازی
  12. گام 1: تعاریف ویژگی‌ها و رجیستری
  13. گام 2: بازپر ذخیره‌ساز آفلاین
  14. گام 3: پیوند مبتنی بر نقطه در زمان
  15. گام 4: سنجش نشتی پیوند ساده‌لوحانه
  16. گام 5: ذخیره‌ساز آنلاین و مادیت‌بخشی
  17. گام 6: سنجش انحراف
  18. کاربرد در عمل
  19. خروجی کاربردی
  20. تمرین‌ها
  21. اصطلاحات کلیدی
  22. برای مطالعه‌ی بیشتر

یکی از رایج‌ترین و کلافه‌کننده‌ترین چالش‌های مهندسی یادگیری ماشین، دیدن مدلی است که در مرحله‌ی ارزیابی آفلاین دقتی رویایی دارد اما به محض رفتن به محیط پروداکشن، عملکردش افت شدیدی پیدا می‌کند. این تناقض اغلب به دلیل پدیده‌ای به نام انحراف آموزش و سرویس‌دهی (Training-Serving Skew) و نشت اطلاعات آینده به داده‌های آموزش رخ می‌دهد؛ جایی که منطق تولید ویژگی‌ها در زمان آموزش با کد اجرایی در زمان سرویس‌دهی تفاوت دارد.

حل این مسئله نیازمند تغییری بنیادین در معماری داده‌های مدل است: انتقال از کدهای پراکنده به یک ذخیره‌ساز ویژگی (Feature Store). ذخیره‌ساز ویژگی با ایجاد یک نقطه‌ی اتکای واحد، تاریخچه‌ی داده‌ها را برای آموزش و تازه‌ترین مقادیر را برای سرویس‌دهی بلادرنگ مهیا می‌کند و جلوی دوگانگی محاسبات را می‌گیرد.

در این درس یاد می‌گیرید که چگونه با تکنیک‌های کلیدی مانند پیوند مبتنی بر نقطه در زمان (Point-in-time Join)، مادیت‌بخشی (Materialization) و مدیریت TTL، معماری خطوط لوله‌ی داده را ایمن کنید و مفاهیم یک ذخیره‌ساز ویژگی متن‌باز مانند Feast را از صفر پیاده‌سازی و تست نمایید.

هر ویژگی یک تعریف دارد و دو خواننده: آموزش، تاریخچه‌ی آن را تا زمان هر برچسب می‌خواند و سرویس‌دهی، تازه‌ترین مقدارش را. هرگاه این دو خواننده با هم ناسازگار شوند، مدل ورودی‌هایی را می‌بیند که هرگز روی آن‌ها آموزش ندیده است.

اهداف یادگیری

  • ذخیره‌ساز آفلاین، ذخیره‌ساز آنلاین، موجودیت‌ها، برچسب‌های زمانی رخداد، و TTL را به همراه وظیفه‌ی هرکدام شرح دهید.
  • یک پیوند مبتنی بر نقطه در زمان (point-in-time correct join) پیاده‌سازی کنید و نشان دهید پیوند ساده‌لوحانه چطور مقادیر آینده را وارد داده‌های آموزش می‌کند.
  • چهار عامل انحراف آموزش و سرویس‌دهی (training-serving skew) را نام ببرید: دو مسیر کدنویسی، ویژگی‌های کهنه، مقادیر پیش‌فرض متفاوت، و سفر در زمان.
  • یک رجیستری ویژگی (feature registry) بسازید که در آن یک تعریف واحد، داده‌های هر دو مسیر آموزش و سرویس‌دهی را تأمین کند.
  • یک ویژگی جدید را روی تاریخ‌های گذشته بازپر (backfill) کنید، بی‌آنکه داده‌های پس از هر تاریخ خوانده شوند.
  • با مقایسه‌ی لاگ ویژگی‌های سرویس‌دهی با مسیر آموزش برای همان موجودیت و زمان، انحراف را شناسایی کنید.

صورت مسئله

تیمی در حال ساخت یک مدل ریزش (churn) است. هر برچسب مشخص می‌کند که آیا کاربر در بازه‌ی 30 روزه‌ی پس از یک روزِ برچسب، خریدی انجام داده است یا خیر. تیم، جدول برچسب‌ها را بر اساس user_id به جدول ویژگی‌های کاربر پیوند می‌زند و قاعده‌ای روی days_since_last_purchase آموزش می‌دهد. دقت آفلاین مدل به 0.948 می‌رسد.

اما روی ترافیک زنده، عملکرد مدل از یک نسخه‌ی قدیمی‌تر و ساده‌تر هم بدتر می‌شود. ماجرا این بود که جدول ویژگی‌ها، تازه‌ترین مقدار هر کاربر را نگه می‌داشت؛ یعنی مقادیری از هفته‌ها پس از روزهای برچسب. کاربری که آخرین خریدش در روز 115 بود، در اسنپ‌شات روز 175، عدد 60 روز بدون فعالیت را نشان می‌داد؛ بنابراین ویژگی عملاً پاسخ مسئله را در خود داشت. هم‌زمان، تیمی دیگر ویژگی‌های سرویس‌دهی را در سرویسی مجزا از نو نوشته بود؛ پنجره‌ی 30 روزه‌ی آن‌ها یک روز اضافه داشت و مقدار پیش‌فرض کاربر جدید را به جای 365، عدد -1 گذاشته بودند.

ریشه‌ی هر دو اشکال یکی است: آموزش و سرویس‌دهی «یک ویژگی یکسان» را به دو روش متفاوت محاسبه می‌کردند. در این درس، اجزای یک ذخیره‌ساز ویژگی (feature store) را از صفر پیاده‌سازی می‌کنید: یک رجیستری، یک بازپر، یک پیوند مبتنی بر نقطه در زمان، یک ذخیره‌ساز آنلاین مجهز به TTL، و یک ارزیابی انحراف. دموی این درس هر دو اشکال را با ارقام واقعی بازسازی می‌کند.

مفاهیم پایه

دو خواننده برای یک ویژگی

آموزش و سرویس‌دهی، ویژگی‌ها را در قالب‌های متفاوتی می‌خواهند:

ویژگی آموزش (آفلاین) سرویس‌دهی (آنلاین)
پرسش «مقدار ویژگی برای هر یک از 10 میلیون جفت (موجودیت، زمان) چقدر بوده است؟» «مقدار ویژگی برای این یک موجودیت، همین حالا چقدر است؟»
زمان نقطه‌های متعددی در گذشته زمان حال
حجم داده میلیون‌ها ردیف در هر جاب یک ردیف در هر درخواست
تأخیر مجاز چند دقیقه مشکلی ندارد چند میلی‌ثانیه
ذخیره‌سازی ذخیره‌ساز آفلاین: تاریخچه‌ی کامل با برچسب‌های زمانی، در فایل‌های ستونی یا انبار داده ذخیره‌ساز آنلاین: تازه‌ترین مقدار برای هر موجودیت، در پایگاه داده‌ی کلید-مقداری

یک ذخیره‌ساز ویژگی، هر دو مخزن داده را بر اساس مجموعه‌ای واحد از تعاریف ویژگی پر نگه می‌دارد. به جابی که تازه‌ترین مقادیر را از ذخیره‌ساز آفلاین در ذخیره‌ساز آنلاین کپی می‌کند، مادیت‌بخشی (materialization) می‌گویند.

رویدادهای خامتعاریف ویژگی‌هادر رجیستریذخیره‌ساز آفلاینتاریخچه همراه با برچسب زماناتصال مقطعی زمانیبا برچسب‌هامجموعه داده آموزشیمادی‌سازیذخیره‌ساز آنلاینآخرین مقدار برای هر موجودیتدرخواست سرویس‌دهیویژگی‌های ثبت‌شدهبررسی اریب

موجودیت‌ها، برچسب‌های زمانی و TTL

چهار مفهوم، هر ردیف از داده‌های ویژگی را توصیف می‌کنند:

  • موجودیت (Entity): کلیدی که ویژگی آن را توصیف می‌کند، مانند user_id یا card_id.
  • برچسب زمانی رخداد (Event timestamp): زمانی که مقدار در دنیای واقعی محقق شد. تعداد خریدهای یک کاربر «تا روز 14»، برچسب زمانی رخداد روز 14 را دارد.
  • برچسب زمانی ایجاد (Created timestamp): زمانی که سیستم، ردیف را ثبت کرد. فرایند بازپری که امروز اجرا می‌شود، ردیف‌هایی با برچسب‌های زمانی رخداد گذشته و برچسب زمانی ایجاد جدید می‌نویسد.
  • طول عمر (TTL یا time to live): بیشترین عمر مجاز یک مقدار. مقداری که عمرش از TTL بیشتر باشد مفقود شمرده می‌شود و خواننده مقدار پیش‌فرض را به کار می‌برد.

به گروهی از ویژگی‌ها با موجودیت، منبع و TTL یکسان، معمولاً نمای ویژگی (feature view) می‌گویند.

پیوندهای مبتنی بر نقطه در زمان

هر ردیف آموزش، یک برچسب را با مقادیر ویژگی‌هایی جفت می‌کند که در زمان آن برچسب وجود داشته‌اند. پیوند مبتنی بر نقطه در زمان این کار را برای هر برچسب انجام می‌دهد:

  1. ردیف‌های ویژگی مربوط به همان موجودیت را پیدا کن.
  2. ردیف‌هایی را نگه دار که برچسب زمانی رخداد آن‌ها در زمان برچسب یا پیش از آن است.
  3. تازه‌ترین ردیف را از میان آن‌ها انتخاب کن.
  4. اگر اختلاف زمان برچسب با برچسب زمانی رخداد آن بیش از TTL باشد، آن را کنار بگذار.

جدول زیر یک کاربر، یک برچسب در روز 16، و ردیف‌های هفتگی ویژگی را نشان می‌دهد:

روز ردیف ویژگی purchases_30d پیوند مبتنی بر نقطه در زمان پیوند ساده‌لوحانه روی user_id
7 4
14 3 انتخاب شد
21 1 آینده
28 0 آینده انتخاب شد

پرسش برچسب این است: آیا کاربر در روزهای 17 تا 46 خریدی انجام می‌دهد؟ ردیف روز 28 از قبل روزهای بی‌فعالیتِ پس از روز برچسب را شمرده است؛ این ردیف عدد 0 را نشان می‌دهد چون کاربر دیگر خریدی نکرده؛ یعنی دقیقاً همان رخدادی که برچسب اندازه می‌گیرد. پیوند ساده‌لوحانه عملاً پاسخ را داخل ورودی کپی می‌کند.

ما در درس سری‌های زمانی داده‌ها را به همین دلیل بر اساس زمان جدا کردیم. پیوند مبتنی بر نقطه در زمان، این قاعده را به جای یک نقطه‌ی برش، روی تک‌تک ردیف‌ها پیاده می‌کند.

دلایل انحراف آموزش و سرویس‌دهی

راهنمای Rules of Machine Learning گوگل، انحراف آموزش و سرویس‌دهی را تفاوت میان عملکرد در زمان آموزش و عملکرد در زمان سرویس‌دهی تعریف می‌کند. این راهنما سه علت را برمی‌شمارد: پردازش متفاوت داده‌ها در دو خط لوله، تغییر در داده‌ها میان آموزش و سرویس‌دهی، و حلقه‌ی بازخورد. در خطوط لوله‌ی ویژگی، چهار علت مشخص بارها و بارها رخ می‌دهند:

علت چه رخ می‌دهد نمونه در دمو
دو مسیر کدنویسی ویژگی در آموزش و سرویس‌دهی با دو کد متفاوت محاسبه می‌شود پنجره‌ی سرویس‌دهی روز now - 30 را هم در نظر می‌گیرد، اما پنجره‌ی آموزش نه
ویژگی‌های کهنه سن مقداری که سرویس‌دهی می‌خواند با سن مقداری که آموزش استفاده کرده متفاوت است آموزش از اسنپ‌شات‌های هفتگی می‌خواند و سرویسی جدید مقادیر تازه را محاسبه می‌کند
پیش‌فرض‌های متفاوت دو مسیر، مقدار گم‌شده را با مقادیر متفاوتی پر می‌کنند آموزش برای «هنوز خریدی نداشته» عدد 365 را می‌گذارد و سرویس‌دهی عدد -1
سفر در زمان آموزش، مقداری از زمان‌های بعد از برچسب را می‌خواند پیوند ساده‌لوحانه تازه‌ترین اسنپ‌شات را برای همه‌ی برچسب‌ها برمی‌دارد

قاعده‌ی شماره‌ی 32 همان راهنما تأکید می‌کند تا حد امکان کدها را میان خط لوله‌ی آموزش و خط لوله‌ی سرویس‌دهی بازاستفاده کنید. یک ذخیره‌ساز ویژگی این قاعده را با داشتن تعریفی یکتا پیاده می‌سازد که هر دو مسیر آن را صدا می‌زنند.

رجیستری ویژگی

رجیستری، کاتالوگ تعاریف ویژگی‌هاست. هر ورودی شامل نام، موجودیت، منبع، تبدیل، پنجره، مقدار پیش‌فرض، TTL، مالک، نسخه، و توضیحات است.

دو قاعده سلامت آن را حفظ می‌کنند:

  • هر دو مسیر، تعریف را از رجیستری می‌خوانند. هیچ سرویسی کپی اختصاصی از تبدیل را پیش خود نگه نمی‌دارد.
  • هر تغییری در تعریف، نسخه‌ای تازه می‌سازد. نسخه‌ی قبلی دست‌نخورده باقی می‌ماند تا مدل‌های قدیمی همان ویژگی‌هایی را داشته باشند که رویشان آموزش دیده‌اند.

بازپرها

یک ویژگی تازه هیچ تاریخچه‌ای ندارد، اما آموزش به مقدار آن در تک‌تک زمان‌های برچسب‌های قدیمی نیاز دارد. یک بازپر تعریف ویژگی را روی تک‌تک تاریخ‌های گذشته اجرا می‌کند و برای هر موجودیت و تاریخ، یک ردیف می‌نویسد. هر بار اجرا تنها رخدادهایی را می‌خواند که برچسب‌های زمانی آن‌ها در همان تاریخ یا پیش از آن باشد.

هزینه‌ی پردازشی بازپر با حاصل‌ضرب تعداد موجودیت‌ها در تعداد تاریخ‌ها رشد می‌کند. این فرایند نشتی ظریفی هم به همراه دارد: بازپری که امروز اجرا می‌شود ردیف‌هایی با برچسب‌های زمانی رخداد گذشته می‌نویسد، اما می‌تواند داده‌هایی را بخواند که با تأخیر رسیده‌اند یا بعداً اصلاح شده‌اند. یک پیوند سخت‌گیرانه‌ی مبتنی بر نقطه در زمان حتی بررسی می‌کند که برچسب زمانی ایجاد نیز در زمان برچسب یا پیش از آن باشد.

شناسایی انحراف

قاعده‌ی شماره‌ی 29 همان راهنما پیشنهاد می‌کند ویژگی‌ها را در زمان سرویس‌دهی لاگ کنید و از آن‌ها در آموزش بهره ببرید. حتی نمونه‌ی کوچکی از لاگ‌ها امکان بررسی سازگاری را فراهم می‌کند. هر مقدار لاگ‌شده را با مقداری مقایسه کنید که مسیر آموزش برای همان موجودیت و زمان تولید می‌کند. برای هر ویژگی، نسبت ناهمخوانی‌ها و میانگین قدر مطلق اختلاف را گزارش دهید.

برای ریشه‌یابی علت، این سه جفت را مقایسه کنید:

  1. خواندن از ذخیره‌ساز آنلاین در برابر پیوند زمان آموزش. این کار کل سیستم را می‌آزماید و باید انحراف صفر نشان دهد.
  2. محاسبه‌ی بلادرنگ در برابر پیوند زمان آموزش. این مقایسه اثر کهنگی را جدا می‌کند.
  3. مسیر دوم کدنویسی در برابر محاسبه‌ی بلادرنگ. این مقایسه تفاوت‌های کد، مانند پنجره‌ها و مقادیر پیش‌فرض را جدا می‌کند.
POINT-IN-TIME JOINروز برچسب را بکشید
POINT-IN-TIME JOINA training row for a label at day t can only use feature values that existed at day t. The point-in-time join takes the latest row on or before the label day, and drops it if it is older than the TTL. A naive join on the user ID takes the newest row in the table. That row often comes from after the label and copies the future into training.3d24d64d116d175d242d311d36روز برچسب 20اتصال ساده d36 را می‌خواندfeature rows for one user: value above, event day below · blue band = TTL window
یک سطر آموزشی برای برچسب در روز t فقط می‌تواند از مقادیر ویژگی‌هایی استفاده کند که در روز t وجود داشته‌اند. اتصال مبتنی بر زمان (point-in-time join) آخرین سطر موجود در روز برچسب یا قبل از آن را برمی‌دارد، و اگر قدیمی‌تر از TTL باشد آن را حذف می‌کند. یک اتصال ساده‌لوحانه روی شناسه کاربر، جدیدترین سطر جدول را برمی‌دارد؛ سطری که اغلب متعلق به بعد از زمان برچسب است و آینده را وارد داده‌های آموزشی می‌کند.

پیاده‌سازی

کد موجود در code/main.py تنها از کتابخانه‌ی استاندارد استفاده می‌کند. زمان به صورت عددی صحیح از روزهاست. یک جاب دسته‌ای (batch job) هفتگی، اسنپ‌شات‌های ویژگی‌ها را در روزهای 7، 14 و به همین ترتیب تا روز 175 ثبت می‌کند.

گام 1: تعاریف ویژگی‌ها و رجیستری

کلاس FeatureDefinition تبدیل را در قالب داده نگه می‌دارد: یک تجمیع، یک پنجره، و یک مقدار پیش‌فرض. متد compute آن هرگز رخدادهای پس از as_of را نمی‌خواند.

@dataclass(frozen=True)
class FeatureDefinition:
    name: str
    entity: str
    aggregate: str
    window_days: int | None
    default: float
    version: int = 1
    owner: str = "growth-team"
    description: str = ""

    def compute(self, events: list[Event], as_of: int) -> float:
        visible = [e for e in events if e.day <= as_of]
        if self.window_days is not None:
            visible = [e for e in visible if e.day > as_of - self.window_days]
        if self.aggregate == "count":
            return float(len(visible))
        if self.aggregate == "sum":
            return round(sum(e.amount for e in visible), 2)
        if self.aggregate == "days_since_last":
            return float(as_of - max(e.day for e in visible)) if visible else self.default
        raise ValueError(f"unknown aggregate {self.aggregate!r}")

متد FeatureRegistry.register تعریف یکسان را دو بار می‌پذیرد، اما اگر تعریفی متفاوت با نام و نسخه‌ی یکسان بیاید، آن را رد می‌کند. تابع fill_defaults به هر دو مسیر مقدار پیش‌فرض یکسانی برای داده‌های مفقود می‌دهد.

یک مدل با ارجاعی مانند spend_30d@1 تعریف ویژگی را سنجاق (pin) می‌کند. متدهای compute و fill_defaults این ارجاع را حل‌وفصل می‌کنند، و نام بدون علامت @ تازه‌ترین نسخه را می‌خواند. ارجاعی که @ دارد اما شماره نسخه ندارد خطای ValueError می‌دهد تا یک اشتباه تایپی نتواند بی‌صدا تازه‌ترین نسخه را بخواند. این ارجاع کلیدِ هر ردیف ذخیره‌شده نیز هست؛ بنابراین بازپر، ذخیره‌ساز آنلاین و خواندن در زمان سرویس‌دهی همگی از تعریف سنجاق‌شده استفاده می‌کنند.

گام 2: بازپر ذخیره‌ساز آفلاین

def backfill(registry: FeatureRegistry, names: list[str], events: list[Event], days: list[int]) -> list[FeatureRow]:
    rows = []
    for entity, entity_events in sorted(group_events(events).items()):
        for day in days:
            rows.append(FeatureRow(entity, day, registry.compute(names, entity_events, day)))
    return rows

دمو سه ویژگی را برای 299 کاربر در 25 روز اسنپ‌شات بازپر می‌کند که حاصل آن 7475 ردیف است.

گام 3: پیوند مبتنی بر نقطه در زمان

ردیف‌های هر موجودیت را یک بار بر اساس روز مرتب کنید. برای هر برچسب، bisect_right آخرین ردیف در روز برچسب یا پیش از آن را پیدا می‌کند. بررسی TTL ردیف‌های بیش از حد کهنه را کنار می‌گذارد.

def point_in_time_join(labels: list[LabelRow], rows: list[FeatureRow], ttl_days: int | None = None) -> list[JoinedRow]:
    by_entity: dict[str, list[FeatureRow]] = defaultdict(list)
    for row in rows:
        by_entity[row.entity].append(row)
    days_index = {}
    for entity, entity_rows in by_entity.items():
        entity_rows.sort(key=lambda r: r.day)
        days_index[entity] = [r.day for r in entity_rows]
    joined = []
    for label in labels:
        days = days_index.get(label.entity, [])
        pos = bisect.bisect_right(days, label.day) - 1
        match = by_entity[label.entity][pos] if pos >= 0 else None
        if match is not None and ttl_days is not None and label.day - match.day > ttl_days:
            match = None
        joined.append(JoinedRow(
            label.entity, label.day, label.label,
            match.day if match else None, match.values if match else None,
        ))
    return joined

گام 4: سنجش نشتی پیوند ساده‌لوحانه

تابع naive_latest_join پیوند را تنها بر اساس موجودیت برقرار می‌کند؛ بنابراین برای هر برچسب، تازه‌ترین ردیف را برمی‌گرداند. دمو برچسب‌هایی را در روزهای 100، 120 و 140 می‌سازد و روی هر مجموعه‌ی داده‌ی آموزش، قاعده‌ای با یک ویژگی برازش می‌دهد. سپس هر دو قاعده را روی برچسب‌های روز 145، با ویژگی‌های مبتنی بر نقطه در زمان می‌آزماید:

  naive join used a snapshot from after the label day for 100% of rows
  offline accuracy, naive join: 0.948 (rule: idle > 53 days)
  offline accuracy, PIT join:   0.893 (rule: idle > 19 days)
  accuracy on day 145, trained with naive join: 0.763
  accuracy on day 145, trained with PIT join:   0.876

پیوند ساده‌لوحانه در حالت آفلاین بهتر به نظر می‌رسد: 0.948 در برابر 0.893. اما روی داده‌های جدید ضعیف‌تر است: 0.763 در برابر 0.876. ویژگی نشت‌کرده آستانه‌ی برازش‌شده را از 19 روز به 53 روز بی‌فعالیت جابه‌جا کرده است.

گام 5: ذخیره‌ساز آنلاین و مادیت‌بخشی

ذخیره‌ساز آنلاین تنها یک ردیف به ازای هر موجودیت نگه می‌دارد و ردیف‌های قدیمی‌تر از TTL را رد می‌کند. متد materialize تنها ردیف‌هایی را می‌نویسد که روز آن‌ها در زمان اجرای جاب یا پیش از آن باشد.

class OnlineStore:
    def __init__(self, ttl_days: int):
        self.ttl_days = ttl_days
        self._latest: dict[str, FeatureRow] = {}

    def write(self, row: FeatureRow) -> None:
        current = self._latest.get(row.entity)
        if current is None or row.day >= current.day:
            self._latest[row.entity] = row

    def read(self, entity: str, now: int) -> dict[str, float] | None:
        row = self._latest.get(entity)
        if row is None or row.day > now or now - row.day > self.ttl_days:
            return None
        return dict(row.values)

با در نظر گرفتن TTL معادل 10 روز، خواندن داده در روز 145 ردیف روز 140 را برمی‌گرداند. خواندن در روز 160، بدون مادیت‌بخشی جدید، مقادیر پیش‌فرض را برمی‌گرداند، چون ردیف 20 روزه شده است.

گام 6: سنجش انحراف

تابع detect_skew دو نما را که کلیدشان (entity, day) است مقایسه می‌کند. این تابع نرخ ناهمخوانی و میانگین قدر مطلق اختلاف را برای هر ویژگی گزارش می‌دهد و اگر بیش از 1 درصد مقادیر تفاوت داشته باشند، ویژگی را علامت می‌زند. اگر این دو نما کلیدهای متفاوتی داشته باشند یا هیچ کلیدی نداشته باشند، خطای ValueError می‌دهد؛ چرا که غیبت یک درخواست می‌تواند انحراف را پنهان کند و بررسی بدون درخواست هم چیزی نشان نمی‌دهد.

def detect_skew(
    training: dict[tuple[str, int], dict[str, float]],
    serving: dict[tuple[str, int], dict[str, float]],
    features: list[str],
    tolerance: float = 1e-6,
    max_mismatch_rate: float = 0.01,
) -> list[SkewReport]:
    if set(training) != set(serving):
        raise ValueError(f"{len(set(training) ^ set(serving))} keys are in only one view: log and compare the same requests")
    if not training:
        raise ValueError("no requests to compare")
    keys = sorted(training)
    reports = []
    for feature in features:
        diffs = [abs(training[k][feature] - serving[k][feature]) for k in keys]
        mismatches = sum(1 for d in diffs if d > tolerance)
        rate = mismatches / len(keys)
        mean_diff = sum(diffs) / len(keys)
        reports.append(SkewReport(feature, len(keys), rate, mean_diff, rate > max_mismatch_rate))
    return reports

دمو درخواست‌های روزهای 141 تا 150 را لاگ می‌کند و سه مقایسه‌ی شرح داده شده در بخش مفاهیم را اجرا می‌کند. استخر درخواست‌ها شامل 20 کاربر جدید بدون تاریخچه است.

python3 code/main.py
=== Skew checks on 383 logged requests, days 141 to 150 ===
  online store read  vs  training join (one definition, same snapshots)
    ok   days_since_last_purchase   mismatch   0.0%  mean |diff|    0.00
    ok   purchases_30d              mismatch   0.0%  mean |diff|    0.00
    ok   spend_30d                  mismatch   0.0%  mean |diff|    0.00
  fresh compute      vs  training join (staleness only)
    SKEW days_since_last_purchase   mismatch  83.0%  mean |diff|    2.77
    SKEW purchases_30d              mismatch  30.5%  mean |diff|    0.38
    SKEW spend_30d                  mismatch  37.1%  mean |diff|   16.77
  second code path   vs  fresh compute (code differences only)
    SKEW days_since_last_purchase   mismatch   5.5%  mean |diff|   20.07
    SKEW purchases_30d              mismatch  12.5%  mean |diff|    0.13
    SKEW spend_30d                  mismatch  12.5%  mean |diff|    5.09

این سه بلوک را به ترتیب بخوانید:

  • یک تعریف، اسنپ‌شات‌های یکسان: ذخیره‌ساز آنلاین و پیوند زمان آموزش، ردیف‌های مادیت‌یافته‌ی یکسانی را می‌خوانند؛ در نتیجه انحراف صفر است.
  • کهنگی: مقدار بلادرنگ در بیشتر درخواست‌ها با اسنپ‌شات هفتگی فرق دارد. در اینجا داده‌ی تازه‌تر لزوماً بهتر نیست، چون مدل بر اساس مقادیری آموزش دیده که تا شش روز کهنگی داشته‌اند.
  • تفاوت‌های کد: مسیر دوم کدنویسی در 12.5 درصد از شمارش‌های پنجره تفاوت دارد؛ چرا که پنجره‌ی آن یک روز اضافه دارد. این مسیر در days_since_last_purchase فقط برای کاربران جدید تفاوت دارد، اما با اختلافی بزرگ: -1 در برابر 365.

کاربرد در عمل

Feast یک ذخیره‌ساز ویژگی متن‌باز است. هر قطعه از کدی که از صفر نوشتیم، معادلی در Feast دارد:

پیاده‌سازی از صفر Feast
FeatureDefinition، FeatureRegistry Entity، FeatureView و Field در یک مخزن ویژگی، ثبت‌شده با feast apply
تاریخچه‌ی FeatureRow یک ذخیره‌ساز آفلاین؛ مثلاً FileSource روی فایل‌های Parquet یا جدولی در انبار داده
point_in_time_join با TTL store.get_historical_features(entity_df=..., features=[...])
materialize feast materialize-incremental <end time>
OnlineStore.read store.get_online_features(features=[...], entity_rows=[...])
بررسی برچسب زمانی ایجاد created_timestamp_column در منبع و filter_by_created_timestamp=True

تعاریف به این شکل نوشته می‌شوند. این کد طبق راهنمای شروع سریع Feast نوشته شده و برای اجرای این درس الزامی نیست:

from datetime import timedelta

from feast import Entity, FeatureView, Field, FileSource
from feast.types import Float32, Int64

user = Entity(name="user", join_keys=["user_id"])

user_activity_source = FileSource(
    name="user_activity_source",
    path="data/user_activity.parquet",
    timestamp_field="event_timestamp",
    created_timestamp_column="created",
)

user_activity = FeatureView(
    name="user_activity",
    entities=[user],
    ttl=timedelta(days=10),
    schema=[
        Field(name="purchases_30d", dtype=Int64),
        Field(name="spend_30d", dtype=Float32),
        Field(name="days_since_last_purchase", dtype=Int64),
    ],
    source=user_activity_source,
)

سپس آموزش و سرویس‌دهی هر دو از یک نمای ویژگی می‌خوانند:

from feast import FeatureStore

store = FeatureStore(repo_path=".")

training_df = store.get_historical_features(
    entity_df=labels_df,
    features=["user_activity:purchases_30d", "user_activity:days_since_last_purchase"],
    filter_by_created_timestamp=True,
).to_df()

online = store.get_online_features(
    features=["user_activity:purchases_30d", "user_activity:days_since_last_purchase"],
    entity_rows=[{"user_id": 42}],
).to_dict()

دیتافریم labels_df به ازای هر برچسب شامل یک ردیف با ستون‌های user_id، event_timestamp و خود برچسب است. مستندات Feast بیان می‌کند که TTL نسبت به هر برچسب زمانی در دیتافریم موجودیت سنجیده می‌شود، نه نسبت به زمان کوئری. این دقیقاً مطابق با بررسی TTL در متد point_in_time_join ما است. همان صفحه filter_by_created_timestamp=True را توصیف می‌کند که مانع نشت مقادیر بازپرشده به داده‌های آموزش می‌شود. همه‌ی ذخیره‌سازهای آفلاین از این گزینه پشتیبانی نمی‌کنند؛ ذخیره‌سازی که فاقد پشتیبانی باشد به جای نادیده گرفتن گزینه، خطا می‌دهد.

ابزارهای Tecton، Hopsworks و ذخیره‌سازهای ویژگی در پلتفرم‌های ابری بزرگ یادگیری ماشین از همین تفکیک ذخیره‌ساز آفلاین و آنلاین استفاده می‌کنند. برای بررسی انحراف، TensorFlow Data Validation می‌تواند داده‌های آموزش را با داده‌های سرویس‌دهی مقایسه کند تا انحراف شناسایی شود. برک و همکاران سیستم اعتبارسنجی داده در TFX (پلتفرم سرتاسری یادگیری ماشین گوگل) را توصیف کرده‌اند که انحراف آموزش و سرویس‌دهی را نیز شامل می‌شود.

خروجی کاربردی

حاصل این درس فایل outputs/skill-feature-skew-audit.md است. این مهارت، یک خط لوله‌ی ویژگی را از نظر سفر در زمان و انحراف آموزش و سرویس‌دهی بازرسی می‌کند. این راهنما پیوند، تعاریف، مقادیر پیش‌فرض، تازگی هر مسیر و لاگ‌گیری مورد نیاز برای بررسی انحراف را گام‌به‌گام مرور می‌کند.

تمرین‌ها

  1. بازه‌ی زمانی اسنپ‌شات‌ها را از 7 روز به 1 روز تغییر دهید. مقایسه‌ی کهنگی را دوباره اجرا کنید و نتیجه را توضیح دهید.
  2. مقدار TTL را روی 3 روز بگذارید. تعداد ردیف‌های آموزشی را که ویژگی‌های خود را از دست می‌دهند و مقدار پیش‌فرض می‌گیرند بشمارید.
  3. روز ایجاد (created day) را به FeatureRow اضافه کنید. کاری کنید پیوند داده‌ها ردیف‌های ثبت‌شده پس از روز برچسب را نادیده بگیرد.
  4. ویژگی spend_7d را به عنوان نسخه‌ی 2 ویژگی spend_30d ثبت کنید. آن را بازپر کنید و هر دو نسخه را از رجیستری چاپ کنید.
  5. تابع legacy_serving_features را تا جایی اصلاح کنید که سومین بررسی انحراف با موفقیت پاس شود. هر تغییر را فهرست کنید.

اصطلاحات کلیدی

اصطلاح اصطلاح رایج معنای دقیق
ذخیره‌ساز ویژگی (Feature store) «پایگاه داده‌ی ویژگی‌ها» سیستمی که ویژگی‌ها را از روی مجموعه‌ای واحد از تعاریف محاسبه می‌کند و به آموزش و سرویس‌دهی تحویل می‌دهد
ذخیره‌ساز آفلاین (Offline store) «داده‌های آموزش» تاریخچه‌ی کامل ویژگی‌ها همراه با برچسب‌های زمانی رخداد، که جاب‌های آموزش و بازپر آن را می‌خوانند
ذخیره‌ساز آنلاین (Online store) «کش ویژگی‌ها» تازه‌ترین مقدار برای هر موجودیت، که مسیر سرویس‌دهی ظرف چند میلی‌ثانیه آن را می‌خواند
موجودیت (Entity) «شناسه» کلیدی که یک ویژگی توصیف می‌کند؛ مانند کاربر یا کارت
برچسب زمانی رخداد (Event timestamp) «زمان وقوع» زمانی که مقدار یک ویژگی محقق شد
پیوند مبتنی بر نقطه در زمان (Point-in-time join) «پیوند as-of» پیوندی که هر برچسب را با تازه‌ترین ردیف ویژگی در زمان برچسب یا پیش از آن جفت می‌کند
طول عمر (TTL) «انقضا» حداکثر عمر یک مقدار پیش از آنکه خواننده آن را مفقود به شمار آورد
مادیت‌بخشی (Materialization) «همگام‌سازی با آنلاین» جابی که تازه‌ترین مقادیر ویژگی را از ذخیره‌ساز آفلاین در ذخیره‌ساز آنلاین کپی می‌کند
بازپر (Backfill) «محاسبه‌ی تاریخچه» اجرای تعریف یک ویژگی در تاریخ‌های گذشته برای ساخت تاریخچه‌ی آن
انحراف آموزش و سرویس‌دهی (Training-serving skew) «در آفلاین که کار می‌کرد» تفاوت میان ویژگی‌ها یا رفتار در آموزش و سرویس‌دهی

برای مطالعه‌ی بیشتر

خودآزمایی

به سؤال‌ها جواب بدهید تا ببینید چقدر از درس در ذهنتان مانده است.

  1. پیش از درستیمی برچسب‌های ریزش کاربر (churn) را فقط بر اساس `user_id` به جدول ویژگی‌های کاربر متصل می‌کند. این جدول جدیدترین مقادیر هر کاربر را نگه می‌دارد. چه مشکلی پیش می‌آید؟
  2. حین درسیک برچسب در روز 20 ثبت شده است. سطرهای ویژگی در روزهای 6، 17 و 24 وجود دارند و TTL برابر 7 روز است. یک اتصال point-in-time از کدام سطر استفاده می‌کند؟
  3. حین درسفرایند آموزش، سابقه خرید مفقود را با 365 روز پر می‌کند. سرویس سرو مدل آن را با -1 پر می‌کند. این چه نوع انحرافی (skew) است؟
  4. حین درسچرا رجیستری ویژگی هنگام تغییر تعریف یک ویژگی، به‌جای ویرایش قبلی، یک نسخه جدید ایجاد می‌کند؟
  5. پس از درسمقادیر لاگ‌شده در زمان سرو دقیقاً با اتصال آموزشی مطابقت دارند، اما مقادیری که در لحظه درخواست محاسبه می‌شوند در 83 درصد درخواست‌ها متفاوت‌اند. این چه چیزی را نشان می‌دهد؟
  6. پس از درسیک فرایند backfill که امروز اجرا می‌شود، سطرهایی را برای تاریخ‌های سال گذشته با استفاده از داده‌هایی که ماه گذشته اصلاح شده‌اند می‌نویسد. یک اتصال point-in-time سخت‌گیرانه چگونه از نشت اطلاعات جلوگیری می‌کند؟

سؤالات متداول

انحراف آموزش و سرویس‌دهی (Training-Serving Skew) چیست؟

تفاوت میان رفتار یا مقادیر ویژگی‌ها در زمان آموزش مدل با زمان سرویس‌دهی آن که می‌تواند ناشی از دو مسیر کدنویسی جداگانه، تفاوت در مقادیر پیش‌فرض، داده‌های کهنه یا سفر در زمان باشد.

پیوند مبتنی بر نقطه در زمان (Point-in-time join) چگونه جلوی نشت داده را می‌گیرد؟

این پیوند با بررسی برچسب زمانی رخدادها، برای هر برچسب تنها از ویژگی‌هایی استفاده می‌کند که تا لحظه‌ی وقوع همان برچسب تولید شده باشند و داده‌های آینده را فیلتر می‌کند.

وظیفه‌ی مادیت‌بخشی (Materialization) در ذخیره‌ساز ویژگی چیست؟

مادیت‌بخشی جابی است که به‌صورت دوره‌ای تازه‌ترین مقادیر ویژگی‌ها را از ذخیره‌ساز آفلاین به ذخیره‌ساز آنلاین کپی می‌کند تا برای سرویس‌دهی بلادرنگ سریعاً در دسترس باشند.

تفاوت ذخیره‌ساز آنلاین و آفلاین در Feature Store چیست؟

ذخیره‌ساز آفلاین تاریخچه‌ی کامل با برچسب‌های زمانی را برای آموزش نگه می‌دارد، در حالی که ذخیره‌ساز آنلاین تنها تازه‌ترین مقدار هر موجودیت را در پایگاه داده‌ای کلید-مقداری برای پاسخ میلی‌ثانیه‌ای ذخیره می‌کند.

ما رو توی گوگل بیشتر ببین

با افزودن نکست ادمین به «منابع ترجیحی» گوگل، درس‌ها و مطالب ما را در بخش اخبار برتر جستجو بیشتر می‌بینید.