Baza wiedzy / Python

pandas drop_duplicates: subset, keep i wybór wersji

Najpierw definiuję duplikat, potem wybieram wiersz. Pokazuję poprawną kolejność keep=last, remisy dat, brakujące klucze i zmianę inplace w pandas 3.1.

Kajo RudzińskiOpublikowano Aktualizacja 4 min czytania

Pixelartowy mechaniczny selektor wybierający po jednej karcie z grup identycznych rekordów

Gdy usuwam powtórzenia, zaczynam od ustalenia, co oznacza jeden rekord. Dwie identyczne linie eksportu mogą być ponownie pobraną wersją tego samego zdarzenia. Dwa zakupy tego samego klienta mogą być całkowicie poprawnymi zdarzeniami. Sam podobny wygląd wierszy nie rozstrzyga tej różnicy.

DataFrame.drop_duplicates() wybiera wiersze według wskazanych kolumn i reguły zachowania powtórzeń. Nie istnieje funkcja najwyższego poziomu pd.drop_duplicates(). W przykładach używam metody na konkretnej tabeli. Dokumentacja opisuje jej parametry oraz wartości domyślne.

Wszystkie poniższe bloki uruchomiłem kolejno w Pythonie 3.14.4, z pandas 3.0.6 oraz 3.1.0 i NumPy 2.5.4, bez PyArrow. W obu środowiskach otrzymałem podane wyniki. Dane są małe i syntetyczne; nie stanowią pomiaru wydajności dużych plików.

Pierwszy, ostatni lub żaden z powtórzonych wierszy

Zbuduję tabelę z pięcioma wierszami. Para A, 10 występuje dwukrotnie, podobnie B, 20. Wiersz C, 30 nie ma kopii. Wydrukuję indeksy, ponieważ pozwalają jednoznacznie sprawdzić, które wystąpienia zostały zachowane.

# example: keep-options
import pandas as pd

rows = pd.DataFrame({
    "account": ["A", "B", "A", "C", "B"],
    "amount": [10, 20, 10, 30, 20],
})
print(rows.drop_duplicates().index.tolist())
print(rows.drop_duplicates(keep="last").index.tolist())
print(rows.drop_duplicates(keep=False).index.tolist())
print(len(rows))
[0, 1, 3]
[2, 3, 4]
[3]
5

Domyślne keep="first" zostawia pierwsze wystąpienie każdej kombinacji. keep="last" zachowuje ostatnie wystąpienia, ale wynik nadal odpowiada kolejności zachowanych wierszy w wejściu: indeksy 2, 3, 4. Nie grupuje kont alfabetycznie i nie układa wyniku jako 2, 4, 3.

Przy keep=False znikają wszystkie wystąpienia powtarzających się kombinacji. Dlatego zostaje wyłącznie konto C. Ta opcja może być przydatna przy wydzielaniu rekordów bez konfliktów, jednak usuwa także pierwszą poprawną kopię. Przed jej użyciem sprawdzam, czy taki właśnie jest cel analizy. Oryginalne rows wciąż ma pięć wierszy, bo zapisane wywołania zwróciły osobne wyniki.

Subset definiuje klucz porównania

Teraz zmienię ostatnią kwotę konta B na 25. Dwa wiersze tego konta zaczną różnić się zawartością. Porównanie całych wierszy zachowa oba, natomiast porównanie samego account wybierze tylko jeden.

# example: subset-and-audit
changed = rows.copy()
changed.loc[4, "amount"] = 25
print(changed.drop_duplicates().index.tolist())
print(changed.drop_duplicates(subset=["account"]).index.tolist())
conflicts = changed.loc[changed.duplicated(subset=["account"], keep=False)]
print(conflicts.index.tolist())
[0, 1, 3, 4]
[0, 1, 3]
[0, 1, 2, 4]

Tutaj subset=["account"] oznacza zgodę na zachowanie pierwszej kwoty dla danego konta. Kolumna amount pozostaje w wyniku, chociaż nie bierze udziału w porównaniu. Jeżeli różne kwoty są kolejnymi zakupami, taka operacja zgubi poprawne dane. Jeśli są wersjami salda, potrzebuję jeszcze reguły wyboru aktualnej wersji.

Do obejrzenia wszystkich uczestników konfliktu użyłem metody duplicated z keep=False. W praktyce najpierw oglądam takie wiersze, a dopiero potem zatwierdzam deduplikację. Klucz złożony zapisuję jako listę, na przykład subset=["account", "amount"]. Każda dodana kolumna zmienia definicję równości.

Pixelartowe wersje rekordów na stopniach obok zegara; najnowsza wersja jest podświetlona

Najnowszy rekord wymaga sortowania i reguły remisu

Słowo „ostatni” odnosi się do bieżącej kolejności tabeli. Samo keep="last" nie odczytuje dat i nie wybiera największej wartości. W kolejnym przykładzie określam regułę: dla konta wybieram najpóźniejszy czas, a przy takim samym czasie największy revision_id.

# example: latest-revision
versions = pd.DataFrame({
    "account": ["A", "A", "B", "A"],
    "updated_at": pd.to_datetime([
        "2026-10-20", "2026-10-22", "2026-10-21", "2026-10-22"
    ]),
    "revision_id": [10, 11, 12, 13],
    "amount": [100, 120, 80, 125],
})
assert versions[["account", "updated_at", "revision_id"]].notna().all().all()
assert versions["revision_id"].is_unique
ordered = versions.sort_values(["account", "updated_at", "revision_id"])
latest = ordered.drop_duplicates(subset=["account"], keep="last")
print(latest[["account", "revision_id", "amount"]].to_dict("records"))
[{'account': 'A', 'revision_id': 13, 'amount': 125}, {'account': 'B', 'revision_id': 12, 'amount': 80}]

Dla A wygrywa wersja 13. Obie ostatnie wersje mają tę samą datę, więc dodatkowy identyfikator rozstrzyga remis. Unikalność identyfikatora i brak pustych kluczy sprawdzam przed wyborem. Bez tych założeń wynik mógłby zależeć od przypadkowej kolejności importu albo od sposobu ustawienia brakujących dat.

W rzeczywistym procesie największy identyfikator musi mieć uzasadnione znaczenie. Losowy UUID sam w sobie nie oznacza nowszej wersji. Gdy źródło nie daje kolejności wersji, zapisuję konflikt do wyjaśnienia. Możliwości sortowania opisuje dokumentacja sort_values; decyzja o zwycięskim rekordzie należy do reguł zbioru danych.

Brakujący klucz nie oznacza tego samego klienta

W poniższej kolumnie o jawnym typie Int64 dwa pd.NA należą do tej samej grupy przy deduplikacji po customer_id. Mechanicznie zostaje jeden z tych wierszy. Analitycznie nie mam jednak dowodu, że dotyczą jednej osoby.

# example: missing-keys
customers = pd.DataFrame({
    "customer_id": pd.Series([1, 1, pd.NA, pd.NA, 2], dtype="Int64"),
    "record": ["a", "b", "c", "d", "e"],
})
print(customers.drop_duplicates(subset=["customer_id"]).index.tolist())
known = customers["customer_id"].notna()
resolved = pd.concat([
    customers.loc[known].drop_duplicates(subset=["customer_id"]),
    customers.loc[~known],
]).sort_index()
print(resolved.index.tolist())
[0, 2, 4]
[0, 2, 3, 4]

Drugi wariant deduplikuje tylko znane identyfikatory i zachowuje oba wiersze bez klucza. sort_index() przywraca tutaj kolejność wejścia, ponieważ przykład ma unikalny, rosnący indeks. Przy innym indeksie potrzebna byłaby osobna kolumna pozycji. Nie zakładam również, że tekst "NULL", pusty napis i rzeczywisty brak są automatycznie równoważne. Typowanie i normalizację ustalam wcześniej.

Indeks, ignore_index i zwracany wynik

Indeks nie uczestniczy w porównywaniu wierszy. Dwa takie same wiersze z różnymi etykietami indeksu nadal są duplikatami. Z kolei powtarzający się indeks przy różnych danych nie powoduje ich usunięcia.

# example: index-and-result
same = pd.DataFrame({"value": [7, 7]}, index=[100, 200])
different = pd.DataFrame({"value": [7, 8]}, index=[100, 100])
print(same.drop_duplicates().index.tolist())
print(different.drop_duplicates()["value"].tolist())
clean = rows.drop_duplicates(ignore_index=True)
print(clean.index.tolist())
print(clean["account"].tolist())
print(len(rows), len(clean))
[100]
[7, 8]
[0, 1, 2]
['A', 'B', 'C']
5 3

ignore_index=True nadaje wynikowi kolejne etykiety. Nie zmienia reguły porównania ani wybranego rekordu. Gdy indeks jest identyfikatorem potrzebnym w audycie, zachowuję go albo wcześniej przenoszę do nazwanej kolumny. Jeżeli ma być częścią klucza deduplikacji, musi znaleźć się w porównywanych kolumnach.

Preferuję przypisanie clean = rows.drop_duplicates(...). Starszy kod może używać inplace=True, co modyfikuje obiekt i zwraca None; przypisanie takiego wyniku do rows utraci referencję do tabeli. W pandas 3.1.0 parametr inplace tej metody został oznaczony jako przestarzały i ma zniknąć w 4.0. Opisuje to informacja o wydaniu i PDEP-8. Nie traktuję go jako gwarancji oszczędności pamięci.

Kontrola przed przyjęciem wyniku

Sprawdzam liczbę rekordów przed operacją, po niej oraz liczbę usuniętych pozycji. Następnie oglądam konflikty i upewniam się, że ponowne zastosowanie tej samej reguły nie zmienia wyniku. Taka stabilność jest przydatna, lecz sama nie dowodzi poprawności klucza: błędna reguła też może dawać stabilny wynik.

Jeżeli potrzebuję listy różnych wartości jednej kolumny, wybieram pandas unique. Jeżeli potrzebuję ich liczby, sprawdzam pandas nunique. drop_duplicates stosuję wtedy, gdy wynikiem mają być całe zachowane wiersze i potrafię uzasadnić wybór każdego z nich.

Najważniejsze w pigułce

Czy keep="last" wybiera najnowszą datę?

Wybiera ostatnie wystąpienie w bieżącej kolejności danych. Najpierw posortuj po dacie i jednoznacznym kryterium remisu, jeśli chcesz zachować najnowszą wersję.

Czy indeks uczestniczy w drop_duplicates?

Nie. Porównywane są kolumny. Jeśli indeks ma należeć do klucza, przenieś go do kolumny i uwzględnij w subset.

Czy warto używać inplace=True?

Preferuj przypisanie zwróconego wyniku. inplace=True zwraca None i nie gwarantuje oszczędności pamięci. W pandas 3.1 parametr tej metody jest przestarzały, z usunięciem planowanym w 4.0.

Kajo Rudziński

Newsletter KajoData

Zostańmy w kontakcie.

Piszę o analizie danych, uczeniu się i zmianie pracy. Dzielę się tym, co pomaga mi ogarniać technologię i zawodowe decyzje. Jeśli lubisz ten sposób myślenia, zapraszam do mojego newslettera.