Разделение строковых ячеек двух столбцов pandas dataframe с несовпадающим количеством составных элементов

Есть:

df = pd.DataFrame({'Program': ['RG', 'RG', 'Set'],
    'Number': [0, 1, 2],
    'Id':  [['100; 500'], ['100; 500'], 1],
    'Values':  [0.7, ['0.9; 0.5'], 0.4]
    })

Необходимо получить:

df = pd.DataFrame({'Program': ['RG', 'RG', 'RG', 'RG', 'Set'],
        'Number': [0, 0, 1, 1, 2],
        'Id':  [100, 500, 100, 500, 1],
        'Values':  [0.7, 0, 0.9, 0.5, 0.4]
        })

Если разбивать каждую ячейку по разделителю отдельно, то будет присвоение значений 'Values' к 'Id', которых быть не должно и увеличивается количество строк из-за этого:

df['Id'] = df['Id'].str.split(';')
df = df.explode('Id')
df['Values'] = df['Values'].str.split(';')
df = df.explode('Values')

Как разбить df, чтобы не потерять привязку 'Id' к 'Values' и лишние строки не добавлять?


Ответы (2 шт):

Автор решения: splash58

Вот, при такой исходной структуре

df = pd.DataFrame({'Program': ['RG', 'RG', 'Set'],
    'Number': [0, 1, 2],
    'Id':  [[100, 500], [100, 500], 1],
    'Values':  [[0.7, 0], [0.9, 0.5], 0.4]
    })

df = df.explode(['Id', 'Values'])

получается, как вы хотите

  Program  Number   Id Values
0      RG       0  100    0.7
0      RG       0  500      0
1      RG       1  100    0.9
1      RG       1  500    0.5
2     Set       2    1    0.4

Но для этого, строковое представление списков надо превратить в реальные списки и сделать их равной длины. Но для такой операции, мне кажется, в pandas нет встроенного механизма

→ Ссылка
Автор решения: Vitalizzare ушел в монастырь

pandas 2.2.3

Предположим, что в интересующих колонках ['Id', 'Values'] находятся данные двух типов: либо число, либо строка внутри списка, представляющая собой перечень чисел, разделенных точкой с запятой:

df = pd.DataFrame(
    {
        "Program": ["RG", "RG", "SET", "BAR"],
        "Number": [10, 20, 30, 40],
        "Id": [["100; 200"], ["300; 400"], 1, 5],
        "Values": [0.7, ["0.9; 0.5"], 0.4, ["0.1; 0.2; 0.3"]],
    }
)

#   Program Number            Id             Values
# 0    'RG'     10  ['100; 200']                0.7
# 1    'RG'     20  ['300; 400']       ['0.9; 0.5']
# 2   'SET'     30             1                0.4
# 3   'BAR'     40             5  ['0.1; 0.2; 0.3']

Сначала нужно привести данные к одному виду. В данном случае это будут строки:

tied_cols = ['Id', 'Values']

data = df.drop(columns=tied_cols).assign(
    Id = df['Id'].explode().astype(str),
    Values = df['Values'].explode().astype(str),
)

#   Program Number          Id           Values
# 0    'RG'     10  '100; 200'            '0.7'
# 1    'RG'     20  '300; 400'       '0.9; 0.5'
# 2   'SET'     30         '1'            '0.4'
# 3   'BAR'     40         '5'  '0.1; 0.2; 0.3'

Теперь есть минимум два пути:

  1. Мы можем применить explode одновременно к обоим столбцам, предварительно позаботившись, чтобы совпадало количество элементов в ячейках одной строки согласно требованию метода: "...their list-like data on same row of the frame must have matching length".

  2. Расщепить столбцы отдельно и соединить результат с помощью join, concat или merge, предварительно добавив внутреннюю нумерацию по каждому исходному индексу данных (после расщепления индекс строки будет продублирован для каждого элемента исходной ячейки, и эти стоки мы дополнительно нумеруем начиная от нуля).

Первый подход можем реализовать, добавив, например, недостающие хвосты к строкам перед расщеплением:

sep = '; '

answer = data.drop(columns=tied_cols).join(
    data[tied_cols].add(
        data[tied_cols]
        .map(lambda s: s.count(sep))
        .apply(lambda rec: rec.max() - rec, axis='columns')
        .map(lambda x: f'{sep}0'*x)
    )
    .map(lambda x: x.split(sep))
    .explode(tied_cols)
    .astype({'Id': int, 'Values': float})
)

#   Program Number  Id Values
# 0    'RG'     10 100    0.7
# 0    'RG'     10 200    0.0
# 1    'RG'     20 300    0.9
# 1    'RG'     20 400    0.5
# 2   'SET'     30   1    0.4
# 3   'BAR'     40   5    0.1
# 3   'BAR'     40   0    0.2
# 3   'BAR'     40   0    0.3

Второй подход даёт возможность заполнить недостающие звенья после соединения:

def split_and_explode(column: pd.Series, sep=None):
    x = column.str.split(sep).explode().to_frame()
    in_group_index = x.groupby(level=0).cumcount()
    return x.set_index(in_group_index, append=True)


print(pd.concat(
    [
        split_and_explode(data["Id"], sep),
        split_and_explode(data["Values"], sep),
    ],
    axis="columns",
))

#         Id Values
# 0 0  '100'  '0.7'
#   1  '200'    NaN
# 1 0  '300'  '0.9'
#   1  '400'  '0.5'
# 2 0    '1'  '0.4'
# 3 0    '5'  '0.1'
#   1    NaN  '0.2'
#   2    NaN  '0.3'
#  ^^^
# Обратите внимание на дополнительный индекс
# на втором уровне, благодаря которому происходит
# правильная последовательность соединения

Если недостающие звенья действительно должны быть нулями, применяем fillna(0), ну а если предполагается, что одно значение распространяется на всю группу, то ffill(). Соединение с основным телом данных производим через join, предварительно сбросив вспомогательный индекс и сохранив исходный:

answer = data.drop(columns=tied_cols).join(
    pd.concat(
        [split_and_explode(data["Id"], sep), split_and_explode(data["Values"], sep)],
        axis="columns",
    )
    .reset_index(level=-1, drop=True)
    .fillna('0')
    .astype({"Id": int, "Values": float})
)

#   Program Number  Id Values
# 0    'RG'     10 100    0.7
# 0    'RG'     10 200    0.0
# 1    'RG'     20 300    0.9
# 1    'RG'     20 400    0.5
# 2   'SET'     30   1    0.4
# 3   'BAR'     40   5    0.1
# 3   'BAR'     40   0    0.2
# 3   'BAR'     40   0    0.3

Важно! Позаботьтесь о том, чтобы строки входной таблицы имели уникальные индексы. Это нужно для правильного группирования развернутых ячеек и корректной работы join.

→ Ссылка