2025, Oct 18 14:32

Polars LazyFrame में डुप्लिकेट जाँच के तेज और बेहतर तरीके

इस लेख में जानें कि Polars LazyFrame में डुप्लिकेट पहचानते समय early exit क्यों नहीं चलता, और तेज विकल्प: single‑column unique बनाम len, या sort+rle_id.

Polars में डुप्लिकेट पहचानना अक्सर ऐसा परिदृश्य लगता है जहाँ जल्दी बाहर निकलना (early exit) सबसे सही उपाय दिखता है: जैसे ही पहला डुप्लिकेट दिखे, रुकें और true लौटा दें. व्यवहार में, सीधा तरीका शॉर्ट‑सर्किट नहीं करता, और यह एक बात ही किसी देखने में सर्वोत्तम योजना को पूरे डेटासेट की पूरी स्कैनिंग में बदल सकती है. नीचे समझाया गया है कि ऐसा क्यों होता है, इसे कैसे परखा जा सकता है, और एकल व बहु‑कॉलम जांच के लिए इसके बजाय क्या अपनाएँ.

समस्या सेटअप

मान लें एक हेल्पर जो चुने गए कॉलमों के सेट पर जैसे ही पहला डुप्लिकेट मिले, true लौटाने की कोशिश करता है. उद्देश्य है is_duplicated और any को जोड़कर जल्दी बाहर निकलना.

def try_fast_dupe_flag(lf: pl.LazyFrame, keys: list[str]) -> bool:
    """Attempt to exit early on first duplicate"""
    return (
        lf.select(
            pl.struct(keys).is_duplicated().any()
        )
        .collect()
        .item()
    )

प्रश्न यह है कि क्या यह तरीका सबसे दक्ष है, खासकर तब जब डुप्लिकेट बहुत शुरुआत में मिलते हों.

यह शॉर्ट‑सर्किट क्यों नहीं करता

चाहे डुप्लिकेट तुरंत मिल जाए, यह अभिव्यक्ति समय से पहले नहीं रुकती. मूल वजह यह है कि is_duplicated को अभिव्यक्ति ग्राफ में बाद वाले any कॉल का कोई ज्ञान नहीं होता. उसे यह सूचना नहीं मिलती कि एक अकेला true पर्याप्त है, इसलिए वह पूरे इनपुट पर गणना पूरी करता है. नतीजतन डुप्लिकेट का स्थान—शुरुआत में हो या अंत में—कुल रनटाइम नहीं बदलता.

आप इसे दो बड़े LazyFrame पाइपलाइनों का समय नापकर देख सकते हैं: एक में डुप्लिकेट शुरुआत में हैं और दूसरे में अंत में. परिणाम लगभग समान आते हैं.

lead = (
    pl.select(x=pl.concat_list(0, pl.int_ranges(0, 100_000_000)))
      .explode('x')
      .lazy()
)
trail = (
    pl.select(x=pl.concat_list(pl.int_ranges(0, 100_000_000), 0))
      .explode('x')
      .lazy()
)
%%timeit
try_fast_dupe_flag(lead, 'x')
# प्रति लूप 5.12 सेकंड ± 290 मि.से. (7 रन का औसत ± मानक विचलन, प्रत्येक में 1 लूप)
%%timeit
try_fast_dupe_flag(trail, 'x')
# प्रति लूप 5.14 सेकंड ± 157 मि.से. (7 रन का औसत ± मानक विचलन, प्रत्येक में 1 लूप)

क्योंकि एक्सप्रेशन ग्राफ के भीतर गणना शॉर्ट‑सर्किट नहीं कर सकती, दोनों मामलों की लागत लगभग समान है. सच में जल्दी रुकना हो तो आपको ऐसा प्लगइन चाहिए जो बीच में ही रोक दे, या फिर map_batches के साथ एक मैनुअल Python लूप; बाद वाला आमतौर पर धीमा होगा, सिवाय अत्यधिक स्थितियों के जब शुरुआती तत्व तुरंत टकरा जाएँ.

map_batches के साथ Python में शॉर्ट‑सर्किटिंग

अगर आप जल्दी रुकने का व्यवहार देखना चाहते हैं, तो map_batches के साथ Python‑आधारित इम्प्लीमेंटेशन ऐसा कर सकता है, यद्यपि Python‑स्तरीय लूप और उसके ओवरहेड के कारण यह तरीका प्रोडक्शन पाइपलाइनों के लिए उपयुक्त नहीं है.

import time
def python_dupe_probe(s: pl.Series) -> pl.Series:
    start = time.time()
    for ii, lval in enumerate(s):
        for jj, rval in enumerate(s[ii + 1:]):
            if time.time() - start > 10:
                print(f"took too long, at i={ii} j={jj}")
                return pl.Series([None], dtype=pl.Boolean)
            if lval == rval:
                return pl.Series([True])
    return pl.Series([False])
lead.select(pl.struct('x').map_batches(python_dupe_probe)).collect()
# यह 0.0 सेकंड में हो जाता है
trail.select(pl.struct('x').map_batches(python_dupe_probe)).collect()
# बहुत समय लग गया, i=0 j=27725000 पर

चरम स्थिति में, जहाँ पहले दो मान बराबर हों, फ़ंक्शन लगभग तुरंत लौट आता है. जब डुप्लिकेट दूर‑दूर हों, तो प्रोसेसिंग बाधक हो जाती है.

बिना early exit के तेज विकल्प

अगर आपको केवल एक कॉलम जाँचना है, तो unique मानों की गिनती को कुल गिनती से तुलना करना बहुत कुशल है, और डुप्लिकेट कहाँ आते हैं, इससे फर्क नहीं पड़ता.

lead.select(pl.col('x').unique().len() != pl.col('x').len()).collect()
trail.select(pl.col('x').unique().len() != pl.col('x').len()).collect()
# प्रत्येक में लगभग 0.7 सेकंड लगते हैं

लेकिन उसी एक कॉलम पर struct‑आधारित unique करने पर प्रदर्शन बहुत गिर जाता है.

lead.select(pl.struct('x').unique().len() != pl.struct('x').len()).collect()
trail.select(pl.struct('x').unique().len() != pl.struct('x').len()).collect()
# प्रत्येक में लगभग 1 मिनट 8 सेकंड लगते हैं

कई कॉलमों के लिए, sorting के बाद rle_id का उपयोग एक व्यावहारिक तकनीक है, जो is_duplicated वाले तरीके से बेहतर निकलती है, हालाँकि यह अभी भी सिंगल‑कॉलम unique बनाम len विधि से धीमी रहती है.

lead.select(
    pl.struct('x').sort().rle_id().max() + 1 != pl.struct('x').len()
).collect()
trail.select(
    pl.struct('x').sort().rle_id().max() + 1 != pl.struct('x').len()
).collect()
# प्रत्येक में लगभग 3 सेकंड लगते हैं

विचार सरल है. sort के बाद बराबर पंक्तियाँ लगातार रन बनाती हैं. यदि सभी पंक्तियाँ अद्वितीय हैं, तो कोई रन नहीं बनता, और अधिकतम rle_id कुल लंबाई से ठीक एक कम होगा, क्योंकि इंडेक्स 0 से शुरू होते हैं.

व्यावहारिक फ़ंक्शन

इन बातों को समेटते हुए, एक उपयोगी हेल्पर सिंगल‑कॉलम की अत्यधिक कुशल रणनीति और मल्टी‑कॉलम rle_id ट्रिक के बीच ब्रांच कर सकता है. यह शॉर्ट‑सर्किट नहीं करता, लेकिन व्यवहार में शुरुआती is_duplicated + any तरीके से तेज रहता है.

def dupe_exists(lazy_ds: pl.LazyFrame, keys: list[str]) -> bool:
    """No early exit, but fast in practice"""
    if len(keys) == 1:
        return (
            lazy_ds.select(
                pl.col(keys[0]).unique().len() != pl.col(keys[0]).len()
            )
            .collect()
            .item()
        )
    else:
        return (
            lazy_ds.select(
                pl.struct(keys).sort().rle_id().max() + 1 != pl.struct(keys).len()
            )
            .collect()
            .item()
        )

यह क्यों मायने रखता है

Polars LazyFrame एक्सप्रेशंस के साथ काम करते समय, यह मान लेना आसान है कि any जैसे ऑपरेटर डाउनस्ट्रीम नोड्स को शॉर्ट‑सर्किट करा देंगे. वास्तविकता में, is_duplicated जैसे नोड्स चेन में बाद की एग्रीगेशन से अनभिज्ञ रहते हैं, इसलिए वे पूरी गणना करते हैं. यह समझ प्रदर्शन संबंधी उलझनों से बचाती है और स्पष्ट करती है कि मैनुअल शॉर्ट‑सर्किट कब वाजिब है. अगर डेटा वितरण में शुरुआती डुप्लिकेट केवल कभी‑कभार आते हैं, तो map_batches के जरिए Python लूप से लाभ मिलने की संभावना कम है. जब डुप्लिकेट लगातार बिल्कुल शुरुआत में हों, तब कोई विशेष प्लगइन या कस्टम लॉजिक उचित ठहर सकता है.

निष्कर्ष

Early exit आकर्षक लगता है, लेकिन केवल इसलिए कि पाइपलाइन में बाद में any है, Polars एक्सप्रेशंस शॉर्ट‑सर्किट नहीं करेंगे. एक कॉलम के लिए, unique की लंबाई को कुल लंबाई से तुलना करना डुप्लिकेट पहचानने का सरल और प्रभावी तरीका है. कई कॉलमों के लिए, sort और rle_id का सहारा लेना बिना Python‑स्तरीय लूप्स के मजबूत विकल्प देता है. यदि सच्चा शॉर्ट‑सर्किट अनिवार्य है, तो व्यवहार्य रास्ते हैं: एक उद्देश्य‑निर्मित प्लगइन, या मैनुअल map_batches इम्प्लीमेंटेशन—और दूसरा केवल चरम मामलों में ही समझ में आता है.

यह लेख StackOverflow के एक प्रश्न (द्वारा: Nicolò Cavalleri) और Dean MacGregor के उत्तर पर आधारित है।