5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

本記事はオプティマインド Location Tech Advent Calendar 2022の17日目の記事となります。

はじめに

コロナ禍の影響により、世の多くの企業の働き方はオンサイト中心からリモート中心へのシフトを余儀なくされ、弊社オプティマインドもその例に漏れず過半数の開発メンバーが現在フルリモート前提で働いています。
フルリモートの是非論については他の方の記事に譲るとして、「フルリモート中心になったことによりslack上でのコミュニケーションが比重を増しているので、slack上での関係性を分析したらコミュニケーション上の現状とか課題とかなんとなく見えてくるのでは」と思い立ちました。

ということで、この記事ではslackのデータを用いた、社員同士の関係性の可視化というかなり「プライバシー的にどうなのか」という疑念が残るトピックについて書いていきます*

(※)一応情報セキュリティ的によろしくない内容を含みそうなトピックなので、コードはそれとなくぼかし、かつ名前やID類は伏せています。その関係で、貼っているコードはそのままでは動かないような形になっているので、参考程度という感じで見ていただければと思います。またトピックの性質上、かなり内輪ネタ感が出てしまいましたがご容赦ください。

データ収集

slackはワークスペース内の会話やメンバーなどの各種情報を取得するためのAPIを公開してくれているので、これを使います。

このAPIから取得できるデータを元に関係性を表現するデータを生成していきます。

チャンネル一覧取得

まずは各チャンネルの一覧を取得します。

import pandas as pd
import requests
from collections import defaultdict

token='xxxxx'

def is_existed_next(res_json):
    try:
        cursor = res_json['response_metadata']['next_cursor']
        return not(cursor is None  or cursor=='')
    except:
        return False
    

def get_channels(token):
    url = "https://slack.com/api/conversations.list"
    header = {"Authorization": "Bearer {}".format(token)}
    data = defaultdict(list)
    cursor = None
    while True:
        param = {"exclude_archived": True}
        if cursor is not None:
            param['cursor'] = cursor

        res = requests.get(url, headers=header, params=param)

        if res.status_code == 200:
            channels = res.json()['channels']
            for channel in channels:
                data['id'].append(channel['id'])
                data['name'].append(channel['name'])
        
        if is_existed_next(res.json()):
            cursor = res.json()['response_metadata']['next_cursor']
        else:
            return pd.DataFrame(data)
        
channels_df = get_channels(token)

この処理で以下のようなchannelのidとnameのDataFrameが取得できます。
スクリーンショット 2022-12-15 1.28.03.png

スレッドの一覧の取得

次にチャンネルのidからスレッドの情報を抽出します。

def is_not_user_message(message):
    return message['type'] != 'message' or 'subtype' in message.keys()

def _get_threads(token, channel_id, oldest_timestamp):
    url = "https://slack.com/api/conversations.history"
    header = {"Authorization": "Bearer {}".format(token)}

    data = defaultdict(list)
    cursor = None
    while True:
        param = {
            "channel": channel_id,
            'oldest': oldest_timestamp,
            'include_all_metadata': True
        }
        if cursor is not None:
            param['cursor'] = cursor
        res = requests.get(url, headers=header, params=param)

        if res.status_code == 200:
            if not 'messages' in res.json():
                continue

            messages = res.json()['messages']
            for message in messages:
                if is_not_user_message(message):
                    continue
                data['user'].append(message['user'])
                data['ts'].append(message['ts'])
                data['text'].append(message['text'])

        if is_existed_next(res.json()):
            cursor = res.json()['response_metadata']['next_cursor']
        else:
            return pd.DataFrame(data)


def get_threads(channel_id_list, oldest_timestamp):
    thread_df = []
    for channel_id in tqdm(channel_id_list):
        _thread_df = _get_threads(token, channel_id, oldest_timestamp)
        _thread_df['channel_id'] = channel_id
        thread_df.append(_thread_df)
    return pd.concat(thread_df)

# 通知用チャンネルなどを削除
drop_channels = ['xxx','yyy','zzz']
channel_id_list = channels_df.query(
    'name not in @drop_channels')["id"].tolist()

oldest_timestamp = datetime(2022, 11, 15).timestamp()

data = []
for channel_id in tqdm(channel_id_list):
    tmp_df = _get_threads(token, channel_id, oldest_timestamp)
    tmp_df['channel_id'] = channel_id
    data.append(tmp_df)

thread_df = pd.concat(data).reset_index(drop=True)

取得できたデータは以下です。(今年の東京支社の忘年会は12月26日のようです。)
スクリーンショット 2022-12-15 1.58.36.png

リプライの取得と各種アクションの抽出

各スレッドに紐づいたリプライを取得し、それに紐づく各種アクションの情報を抽出します。

from retry import retry


def gen_relation_dict():
    return {'to_user': [], 'from_user': [], 'action': [], 'channel_id': []}


def get_stamp(message, channel_id):
    data = gen_relation_dict()
    try:
        to_user = message['user']
        for reaction in message['reactions']:
            for from_user in reaction['users']:
                data['to_user'].append(to_user)
                data['from_user'].append(from_user)
                data['action'].append('stamp')
                data['channel_id'].append(channel_id)
        return pd.DataFrame(data)
    except:
        return pd.DataFrame(gen_relation_dict())


def get_mention(message, channel_id):
    data = gen_relation_dict()
    try:
        for element in message['blocks'][0]['elements'][0]['elements']:
            if element['type'] == 'user':
                data['from_user'].append(message['user'])
                data['to_user'].append(element['user_id'])
                data['action'].append('mention')
                data['channel_id'].append(channel_id)
        return pd.DataFrame(data)
    except:
        return pd.DataFrame(gen_relation_dict())


def get_reply(message, channel_id):
    data = gen_relation_dict()
    try:
        data['from_user'].append(message['user'])
        data['to_user'].append(message['parent_user_id'])
        data['action'].append('reply')
        data['channel_id'].append(channel_id)
        return pd.DataFrame(data)
    except:
        return pd.DataFrame(gen_relation_dict())


# rate limit対策
@retry(tries=3, delay=60)
def get_conversations(token, channel_id, ts):
    url = "https://slack.com/api/conversations.replies"
    header = {"Authorization": "Bearer {}".format(token)}
    param = {"channel": channel_id, 'ts': ts}
    res = requests.get(url, headers=header, params=param)
    if res.status_code != 200:
        raise Exception
    return res.json()


data = []
for channel_id, ts in tqdm(thread_df[['channel_id', 'ts']].values):
    conversations = get_conversations(token, channel_id, ts)
    for message in conversations['messages']:
        data.append(get_stamp(message, channel_id))
        data.append(get_mention(message, channel_id))
        data.append(get_reply(message, channel_id))
conversation_df = pd.concat(data).reset_index(drop=True)

ここまでの処理で以下のような、「誰が誰に対してどのようなアクションを行なったのか」という情報が取得できました。

可視化

次に取得したデータを可視化するために整形していきます。

関係性の表現について

今回は各メンバーをノード、関係強度をエッジとして持つ重みつきグラフとして表現します。
その上で関係強度に寄与する要素として以下のアクションを考慮しました。

  • スタンプ
  • リプライ
  • メンション

今回は、関係強度への寄与度はスタンプ<リプライ=メンションという大小関係で設定しました。
これは情報量的にスタンプよりも実際にテキストで会話をしている方が寄与率が高いだろうという仮定のもとそうしています。

では実際にconversation_dfを元にユーザ間の関係度を各アクション毎に決めた重みづけでスコアリングしていきます。

def compose_matrix(conversation_df,weight={"mention": 5,"reply": 5,"stamp": 1}):
    user_list = conversation_df["to_user"].tolist()
    user_list.extend(conversation_df["from_user"].tolist())
    user_list = sorted(list(set(user_list)))
    mat = np.zeros([len(user_list), len(user_list)])
    for from_user, to_user, action in conversation_df[[
            "from_user", "to_user", "action"
    ]].values:
        from_index = user_list.index(from_user)
        to_index = user_list.index(to_user)
        mat[to_index][from_index] += weight[action]
    return mat, user_list


def compose_edge_list(matrix, user_list,is_exclude_zero=True):
    """(user_A,user_B,weight)を要素として持つlistを生成"""
    edge_list = []
    for i in range(matrix.shape[0]):
        for j in range(i + 1, matrix.shape[1]):
            weight = matrix[i][j] + matrix[j][i]
            if is_exclude_zero and weight==0:
                continue
            edge_list.append((user_list[i], user_list[j], weight))
    return edge_list


matrix, user_list = compose_matrix(conversation_df)
weighted_edge_list = compose_edge_list(matrix, user_list)

ようやくデータの準備ができたので、早速こちらを可視化してみます。

from pyvis.network import Network
import networkx as nx

G = nx.Graph()
G.add_weighted_edges_from(weighted_edge_list)

g = Network(notebook=True, height="750px", width="100%")
g.from_nx(G)

g.show_buttons(filter_=['physics', 'nodes'])
g.show("slack_relation.html")

カオスな感じがスタートアップらしくて良い感じなのですが、エッジが太すぎたりしてなんのことやら分からないので整えていきます。
とりあえず重みの分布をみます。
bokeh_plot (4).png
かなり外れ値がある分布のようなので適当に分位数で量子化すればよさそうでしょうか。
というわけで量子化を行い、一番弱い分類の関係強度であるエッジ群を削除し、ついでにチーム毎にノードを彩色した結果が以下です。
スクリーンショット 2022-12-16 1.15.28.png
色はオプティマインド社内の各チーム1を表していて、真ん中にあらゆる方面に強い繋がりを持っているオレンジ色の「DI(仮名)」さんはコーポレート部の方です。社員に対して定期的に各種通知を送ってくれるのでこのような結果になっているようです。

次に、開発チーム同士の繋がりに着目するために、開発部所属のチームのみで可視化してみます。
スクリーンショット 2022-12-16 1.45.16.png
配色の対応関係は、以下の通りです。

  • 赤色:データ基盤チーム(地図、GPSなど)
  • 青色:最適化チーム
  • 茶色:経路探索チーム
  • 緑:SAチーム(WebAppなど)
  • オリーブ:プロダクトチーム

経路探索チームはチーム内でのつながりが非常に強くSlack上でのコミュニケーションが活発な様子がわかります。
また、所々ハブになっているメンバーがいて(DF,MN,BP,JMなど)、そのメンバーは各チームリーダであることが多いようです。このとき、以下のような潜在的リスクが考えられるかもしれません。

  • ハブになる人に情報が集約されてしまっていて全体感を他チームメンバーが把握できていない
  • 開発プロジェクト単位でチーム内ですらサイロ化が起きてしまっている

むしろリーダなど特定のメンバーがハブになってコミュニケーションパスを意図的に絞るのが良いケースもあると思うので一概には言えませんが、少なくとも自分の所属するチームでは上記のような懸念があり、適切な粒度での情報共有が出来ているだろうかと考えさせられました。

感想

今回はSlack上のPublicチャンネルに限定して関係性を分析してみました。
そのため、Privateチャンネルや、Slack以外のコミュニケーションツール(GoogleMeet2,Gather3など)における関係性は考慮できていません。なので、あくまで参考程度にはなりますが個人的には学びがあったので、皆さんも自社組織の分析をやってみると何かしら気づきがあると思うのでぜひお試しください。

最後に

本記事を読んでオプティマインドの組織について興味を持っていただけた方は、弊社採用資料により詳しい内容が書かれていますのでぜひご覧ください。また、もっと詳しく知りたいと思っていただけた方はカジュアル面談も大歓迎ですので、気軽にお声がけください。

  1. https://recruit.optimind.tech/

  2. https://apps.google.com/intl/ja/meet/

  3. https://www.gather.town/

5
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?