327 lines
14 KiB
Markdown
327 lines
14 KiB
Markdown
|
|
# دیتاست: تحلیل کیفیت و ترمیم
|
|||
|
|
|
|||
|
|
<div dir="rtl">
|
|||
|
|
|
|||
|
|
فایل `data/linkedin_profiles.csv` یک export واقعی و خراب است. این سند نشان میدهد دقیقاً چه مشکلی دارد، چرا خواندن ساده جواب نمیدهد، و ماژول [`apps/profiles/dataset.py`](../backend/apps/profiles/dataset.py) چطور آن را ترمیم میکند.
|
|||
|
|
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## ۱. آنچه در فایل هست
|
|||
|
|
|
|||
|
|
<div dir="rtl">
|
|||
|
|
|
|||
|
|
| سنجه | مقدار |
|
|||
|
|
|---|---|
|
|||
|
|
| خطوط فیزیکی (بدون هدر) | ۳۳۶ |
|
|||
|
|
| ستونهای هدر | ۷۷ |
|
|||
|
|
| رکوردهای منطقی بعد از بازسازی | ۳۲۳ |
|
|||
|
|
| رکوردهای یکتا بعد از حذف تکراری | **۲۸۴** |
|
|||
|
|
| ردیفهایی که با خواندن ساده کاملاً سالماند | **۱۰** |
|
|||
|
|
|
|||
|
|
آن عدد آخر مسئله را خلاصه میکند: `csv.DictReader` روی این فایل **کار میکند** — بدون خطا، بدون هشدار — و ۹۷٪ رکوردها را غلط تحویل میدهد.
|
|||
|
|
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## ۲. چهار نقص
|
|||
|
|
|
|||
|
|
### ۲.۱ واترمارک تزریقشده
|
|||
|
|
|
|||
|
|
<div dir="rtl">
|
|||
|
|
|
|||
|
|
بسیاری از ردیفها یک ستون اضافه در ابتدا دارند:
|
|||
|
|
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
H:\New folder\leak\international\linkedin\@BreachedData1 Lin...,joseph holland,joseph,...
|
|||
|
|
└──────────────────── ستون شبح ────────────────────────────┘ └─ داده واقعی از اینجا شروع میشود
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
<div dir="rtl">
|
|||
|
|
|
|||
|
|
نتیجه: کل ردیف یک خانه به راست میلغزد. `full_name` مقدار واترمارک را میگیرد، `first_name` نام کامل را، و به همین ترتیب تا آخر.
|
|||
|
|
|
|||
|
|
**ترمیم:** هر ستون ابتدایی که شامل `@BreachedData` یا `H:\New folder` باشد حذف میشود.
|
|||
|
|
|
|||
|
|
### ۲.۲ ستونهای غایب (لغزش به چپ)
|
|||
|
|
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
هدر: ..., job_title, job_title_role, job_title_levels, job_company_id, ...
|
|||
|
|
ردیف: ..., "assistant process engineer", "[]", "aerospace-alu...", ...
|
|||
|
|
▲
|
|||
|
|
مقدار job_title_levels است، نه job_title_role
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
<div dir="rtl">
|
|||
|
|
|
|||
|
|
رکورد `job_title_role` را ندارد و بهجای نوشتن مقدار خالی، آن را کلاً ننوشته است. همهچیز بعدش یک خانه به چپ میلغزد. این نقص در **بیشتر** ردیفها وجود دارد و گاهی چند فیلد پشت هم غایباند.
|
|||
|
|
|
|||
|
|
اثرش با یک نمونه واقعی:
|
|||
|
|
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
naive: linkedin_connections = "55,000-70,000" ← این بازه حقوق است!
|
|||
|
|
inferred_salary = "18.0" ← این سال تجربه است!
|
|||
|
|
repaired:linkedin_connections = 32
|
|||
|
|
inferred_salary = "55,000-70,000"
|
|||
|
|
inferred_years_exp = 18
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### ۲.۳ کوتیشن شکسته (یک رکورد در چند خط)
|
|||
|
|
|
|||
|
|
<div dir="rtl">
|
|||
|
|
|
|||
|
|
چند مقدار `summary` یک `"` فرارنشده دارند (`... Leadership and Management."`). پارسر CSV آن را پایان فیلد میفهمد و بقیه رکورد به خط بعد میریزد. نتیجه: ردیفهایی با ۶، ۹، ۳۴ یا ۳۶ ستون که ادامه رکورد قبلیاند.
|
|||
|
|
|
|||
|
|
### ۲.۴ دو export به هم چسبیده
|
|||
|
|
|
|||
|
|
خط هدر یک بار دیگر در خط ۲۵۳ تکرار شده و ۳۹ نفر دقیقاً تکراریاند. یعنی فایل حاصل `cat export1.csv export2.csv` است.
|
|||
|
|
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
توزیع تعداد ستون در ردیفهای فیزیکی:
|
|||
|
|
77 ستون → ۲۸۳ ردیف (ولی اکثرشان با لغزش داخلی)
|
|||
|
|
78 ستون → ۲۵ ردیف (واترمارکدار)
|
|||
|
|
100-133 → ۱۵ ردیف (summary شکسته + واترمارک)
|
|||
|
|
6-47 → ۱۳ ردیف (تکههای ادامه رکورد)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## ۳. الگوریتم ترمیم
|
|||
|
|
|
|||
|
|
### ۳.۱ ایده
|
|||
|
|
|
|||
|
|
<div dir="rtl">
|
|||
|
|
|
|||
|
|
هر ستون یک **امضا** دارد — یک قاعده ساده و قابل بررسی درباره اینکه مقدار معتبرش چه شکلی است:
|
|||
|
|
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
FIELD_VALIDATORS = {
|
|||
|
|
"linkedin_url": _contains("linkedin.com/in/"),
|
|||
|
|
"gender": _one_of({"male", "female"}),
|
|||
|
|
"linkedin_id": _matches(r"\d{3,20}"),
|
|||
|
|
"location_geo": _matches(r"-?\d+\.\d+,-?\d+\.\d+"),
|
|||
|
|
"job_company_size": _matches(r"(1-10|11-50|51-200|201-500|...)"),
|
|||
|
|
"skills": _list_of_str, # literal لیست پایتون
|
|||
|
|
"experience": _list_of_dicts("company"), # لیست دیکشنری با کلید company
|
|||
|
|
"version_status": _dict_with("status"),
|
|||
|
|
... # ۴۶ ستون امضا دارند
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
<div dir="rtl">
|
|||
|
|
|
|||
|
|
ستونهایی که امضا ندارند (نام، عنوان شغلی، صنعت، خلاصه) هر متنی را میپذیرند.
|
|||
|
|
|
|||
|
|
سپس مسئله این میشود: **بهترین انطباق بین دنباله مقادیر ردیف و دنباله ستونهای هدر چیست؟** این دقیقاً مسئله همترازی دنباله است.
|
|||
|
|
|
|||
|
|
### ۳.۲ همترازی بهسبک Needleman–Wunsch
|
|||
|
|
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
ستونهای هدر →
|
|||
|
|
full_name first_name gender linkedin_url job_title_role ...
|
|||
|
|
مقادیر "jane doe" ↘
|
|||
|
|
ردیف "jane" ↘
|
|||
|
|
↓ "female" ↘
|
|||
|
|
"linkedin.com/in/j" ↘
|
|||
|
|
"['senior']" ⤵ (skip column)
|
|||
|
|
job_title_levels ↘
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# سه حرکت مجاز در هر خانه جدول برنامهنویسی پویا
|
|||
|
|
match = dp[i-1][j-1] + score(column[j], value[i]) # این مقدار به این ستون
|
|||
|
|
skip_column = dp[i][j-1] - 0.5 # رکورد این فیلد را ندارد
|
|||
|
|
drop_value = dp[i-1][j] - 3.5 (یا -0.3 اگر مقدار خالی) # تکه سرگردان
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
<div dir="rtl">
|
|||
|
|
|
|||
|
|
**وزنها و منطقشان:**
|
|||
|
|
|
|||
|
|
| رویداد | امتیاز | چرا |
|
|||
|
|
|---|---|---|
|
|||
|
|
| تطابق امضا | **+۳** | قویترین شاهد؛ باید بر هر ترکیبی از ویرایشها بچربد |
|
|||
|
|
| نقض امضا | **−۴** | «شماره تلفن در ستون ایمیل» باید گرانتر از حذف باشد |
|
|||
|
|
| متن آزاد (بدون امضا) | +۰٫۳ | پاداش کوچک برای مصرف کردن مقدار |
|
|||
|
|
| مقدار خالی | +۰٫۱۵ | خنثی؛ هر جایی مینشیند |
|
|||
|
|
| رد کردن ستون | −۰٫۵ | ارزان: در این فایل خیلی رایج است |
|
|||
|
|
| دور انداختن مقدار غیرخالی | **−۳٫۵** | گران: از دست دادن داده آخرین راهحل است |
|
|||
|
|
| دور انداختن مقدار خالی | −۰٫۳ | padding انتهای ردیف بیارزش است |
|
|||
|
|
|
|||
|
|
پیچیدگی O(n×m) با n≈۷۷ و m=۷۷ ⇒ حدود ۶۰۰۰ خانه در هر ردیف. کل فایل در حدود ۱۵ ثانیه (که بیشترش صرف نوشتن در دیتابیس میشود، نه همترازی).
|
|||
|
|
|
|||
|
|
### ۳.۳ خط لوله کامل
|
|||
|
|
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
۱. حذف ستون واترمارک از ابتدای هر ردیف
|
|||
|
|
▼
|
|||
|
|
۲. رد کردن خط هدرِ تکراری وسط فایل
|
|||
|
|
▼
|
|||
|
|
۳. تشخیص شروع رکورد:
|
|||
|
|
«full_name و first_name وجود دارند و full_name با first_name شروع میشود»
|
|||
|
|
ردیفی که شروع رکورد نیست ⇒ ادامه رکورد قبلی است
|
|||
|
|
▼
|
|||
|
|
۴. الحاق تکههای ادامه به رکورد قبلی — فقط اگر رکورد قبلی خودش ناقص باشد.
|
|||
|
|
وگرنه بهعنوان «تکه یتیم» شمرده و کنار گذاشته میشود (۱۲ مورد).
|
|||
|
|
دلیل: چسباندن یک تکه به رکوردی که کامل است، داده یک نفر را به نام
|
|||
|
|
نفر دیگر ثبت میکند — بدترین حالت ممکن.
|
|||
|
|
▼
|
|||
|
|
۵. همترازی DP
|
|||
|
|
▼
|
|||
|
|
۶. اعتبارسنجی دوم: هر مقداری که هنوز امضایش را رد نکند حذف میشود
|
|||
|
|
(۱۷۵ مقدار در کل فایل)
|
|||
|
|
▼
|
|||
|
|
۷. حذف تکراری بر اساس linkedin_id → linkedin_url → full_name (۳۹ مورد)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
<div dir="rtl">
|
|||
|
|
|
|||
|
|
**چرا مرحله ۶ لازم است؟** همترازی بهترین انطباق ممکن را پیدا میکند، ولی «بهترین» همیشه «درست» نیست. مثال واقعی: در یک رکورد، ستون `work_email` مقدار `"* We are focussed on providing consumer nutritional products"` گرفته بود. اعتبارسنجی دوم (`باید شامل @ باشد`) آن را دور میاندازد. **ذخیره نکردن بهتر از ذخیره کردن داده اشتباه است.**
|
|||
|
|
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## ۴. گزارش ایمپورت
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
$ docker compose exec backend python manage.py import_profiles --truncate
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"profiles_created": 284,
|
|||
|
|
"profiles_updated": 0,
|
|||
|
|
"skills": 2376,
|
|||
|
|
"companies": 1371,
|
|||
|
|
"schools": 610,
|
|||
|
|
"experiences": 2128,
|
|||
|
|
"educations": 870,
|
|||
|
|
"physical_rows": 336,
|
|||
|
|
"duplicate_headers": 1,
|
|||
|
|
"repaired_rows": 16,
|
|||
|
|
"orphan_fragments": 12,
|
|||
|
|
"duplicate_records": 39,
|
|||
|
|
"discarded_values": 175,
|
|||
|
|
"records": 284,
|
|||
|
|
"elapsed_seconds": 15.41
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
<div dir="rtl">
|
|||
|
|
|
|||
|
|
هر عدد قابل ردیابی است. یک ایمپورت که «۲۸۴ رکورد» میگوید ولی نمیگوید چند مقدار دور ریخته، قابل اعتماد نیست.
|
|||
|
|
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## ۵. غنیسازی از داده تودرتو
|
|||
|
|
|
|||
|
|
<div dir="rtl">
|
|||
|
|
|
|||
|
|
ستونهای سطحبالا اغلب غایباند، ولی همان اطلاعات معمولاً داخل بلوب `experience` هست:
|
|||
|
|
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
[{'company': {'name': 'garver', 'size': '501-1000', 'industry': 'civil engineering', ...},
|
|||
|
|
'title': {'name': 'recruiting manager', 'role': 'human_resources',
|
|||
|
|
'sub_role': 'recruiting', 'levels': ['manager']},
|
|||
|
|
'is_primary': True, 'start_date': '2019-10', 'end_date': None}]
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
<div dir="rtl">
|
|||
|
|
|
|||
|
|
`ProfileImporter._derive_current_position()` و `_resolve_current_company()` وقتی ستون سطحبالا خالی است، از رکورد `is_primary` استفاده میکنند:
|
|||
|
|
|
|||
|
|
| فیلد | فقط ستون سطحبالا | با غنیسازی | بهبود |
|
|||
|
|
|---|---|---|---|
|
|||
|
|
| `job_title_role` | ۱۰۸ | **۱۸۴** | +۷۰٪ |
|
|||
|
|
| کارفرمای فعلی | ۲۱۹ | **۲۶۵** | +۲۱٪ |
|
|||
|
|
| `job_title` | ۲۴۸ | **۲۵۹** | +۴٪ |
|
|||
|
|
|
|||
|
|
این تفاوت مستقیماً روی کیفیت فیلترها اثر میگذارد: فیلتر «خانواده شغلی» با ۱۰۸ رکورد تقریباً بیفایده است، با ۱۸۴ رکورد کاربردی میشود.
|
|||
|
|
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## ۶. نرمالسازی
|
|||
|
|
|
|||
|
|
<div dir="rtl">
|
|||
|
|
|
|||
|
|
| مورد | قاعده |
|
|||
|
|
|---|---|
|
|||
|
|
| مهارتها | slug (`slugify`) کلید یکتاست ⇒ «Team Building» و «team building» یک رکورد |
|
|||
|
|
| مهارتهای تکراری در یک پروفایل | حذف، با حفظ ترتیب اصلی در `ProfileSkill.position` |
|
|||
|
|
| سقف مهارت | ۶۰ مهارت اول (بلندترین لیست ۵۰+ مورد دارد) |
|
|||
|
|
| اعداد | `"18.0"` → `18` با بازه معتبر (`0 ≤ years ≤ 70`)؛ خارج از بازه ⇒ `None` |
|
|||
|
|
| تاریخ | `"2019-10"` و `"2008"` هر دو نگه داشته میشوند + سال استخراجشده در `start_year`/`end_year` برای فیلتر |
|
|||
|
|
| شرکت/دانشگاه | یکتا بر اساس نام، با کش درونحافظهای در ایمپورتر ⇒ بدون N+1 |
|
|||
|
|
| GPA | فقط اگر بین ۰ و ۱۰ باشد |
|
|||
|
|
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## ۷. امنیت پارس کردن
|
|||
|
|
|
|||
|
|
<div dir="rtl">
|
|||
|
|
|
|||
|
|
ستونهای تودرتو با نگارش `repr` پایتون نوشته شدهاند (`'` بهجای `"` و `None` بهجای `null`)، پس `json.loads` کار نمیکند.
|
|||
|
|
|
|||
|
|
از `ast.literal_eval` استفاده شده، **نه** `eval`. تفاوت حیاتی است: `literal_eval` فقط ساختارهای داده ثابت (عدد، رشته، لیست، دیکشنری، `None`، `True`/`False`) را میپذیرد و هیچ کدی اجرا نمیکند. یک فایل CSV مخرب نمیتواند از این مسیر کد اجرا کند.
|
|||
|
|
|
|||
|
|
همه فراخوانیها در `_safe_literal()` بستهبندی شدهاند که `ValueError`، `SyntaxError`، `MemoryError` و `RecursionError` را میگیرد و `None` برمیگرداند.
|
|||
|
|
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## ۸. تستها
|
|||
|
|
|
|||
|
|
<div dir="rtl">
|
|||
|
|
|
|||
|
|
`backend/tests/test_dataset_repair.py` هفت تست دارد که هر کدام یکی از نقصهای واقعی را در کوچکترین شکل ممکن بازتولید میکنند:
|
|||
|
|
|
|||
|
|
| تست | تضمین |
|
|||
|
|
|---|---|
|
|||
|
|
| `test_clean_row_is_read_verbatim` | ردیف سالم دستنخورده میماند |
|
|||
|
|
| `test_leading_watermark_column_is_stripped` | ستون شبح حذف میشود |
|
|||
|
|
| `test_missing_column_does_not_shift_the_rest_of_the_row` | ستون غایب باعث لغزش بقیه نمیشود |
|
|||
|
|
| `test_values_that_fail_their_signature_are_discarded` | مقدار نامعتبر ذخیره نمیشود |
|
|||
|
|
| `test_repeated_header_and_duplicate_person_are_removed` | هدر و رکورد تکراری حذف میشوند |
|
|||
|
|
| `test_row_split_by_a_stray_quote_is_reassembled` | رکورد شکسته دوباره ساخته میشود |
|
|||
|
|
| `test_empty_file_yields_no_records` | فایل خالی کرش نمیکند |
|
|||
|
|
|
|||
|
|
بدون این تستها، یک تغییر کوچک در وزنهای همترازی میتواند بیصدا کیفیت داده را خراب کند — و هیچکس تا ماهها متوجه نشود.
|
|||
|
|
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## ۹. مسیر جایگزین
|
|||
|
|
|
|||
|
|
<div dir="rtl">
|
|||
|
|
|
|||
|
|
اگر فردا یک export تمیز برسد، هیچ چیز خراب نمیشود: ردیف سالم امتیاز کامل میگیرد و همترازی دقیقاً همان `dict(zip(header, row))` را تولید میکند. الگوریتم ترمیم روی داده سالم بیاثر است — فقط روی داده خراب فعال میشود.
|
|||
|
|
|
|||
|
|
</div>
|