1
3

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 5 years have passed since last update.

有価証券報告書(XBRL)の「従業員の状況」から従業員数や平均年収の数値を取得し整形する試み(その2)

Last updated at Posted at 2019-01-20

1.XBRLの読み取り

1-1.読み取り処理と整形処理の分離

 前回の続き。XBRLファイルからタグ「jpcrp_cor:InformationAboutEmployeesTextBlock」の値を読み込む処理と、そのタグ内のHTMLの記述から従業員の表を抜き出して、平均年収の値を抽出する整形処理とを分離することにした。
 以下は、前回記述したスクリプトの**def fn_parse()の箇所を修正して抜粋したスクリプト。従業員の状況についてのタグ「jpcrp_cor:InformationAboutEmployeesTextBlock」の中身をBeautifulSoupで読み込んで、find_all()でtableの部分を抜き出して変数lstにappend関数で格納して、csvファイルを生成している。あとdef fn_parse()**の引数を1個追加して、有報キャッチャーで閲覧可能な有価証券報告書のpdfのurlを指定して、保存するcsvファイルに有報キャッチャーの該当有報pdfのURLもセットするようにしている。
(注:理由は後述するが、整形に失敗して平均年間給与の値が取得できない場合がどうしても生じるので、後で人間が目視で該当の有報pdfを参照して確認するという状況を想定し...ツラい(泣))

read_xbrl.py
def fn_parse(sic , xbrl_path , pdf):

    # 変数宣言
    lst = []  #取得項目リスト
    
    ### タクソノミーURLの取得(using beautiful Soup)
    ff = open( xbrl_path , "r" ,encoding="utf-8" ).read() 
    soup = BeautifulSoup( ff ,"html.parser")

    x = soup.find_all("xbrli:xbrl" )
    x = str(x)[0:2000] 
    x = x.rsplit("xmlns")

    # 該当attr(crp)の取得
    crp  =['{' + i.replace( i[0:11] ,"").rstrip(' ').replace('"',"") + '}' for i in x[1:10] if i[0:11] == ':jpcrp_cor=']
    dei  =['{' + i.replace( i[0:11] ,"").rstrip(' ').replace('"',"") + '}' for i in x[1:10] if i[0:11] == ':jpdei_cor=']
    crp,dei = crp[0] ,dei[0]
    emp_c=0
    
    # XML項目の取得(using elementTree)
    tree = ET.parse( xbrl_path ) 
    root = tree.getroot() 

    for c1 in root:
          if c1.tag==dei +'EDINETCodeDEI':lst.append( str(c1.text) )
          if c1.tag==crp +'NearestPlaceOfContactCoverPage':lst.append(  str(c1.text)  )
          if c1.tag==dei +'CurrentPeriodEndDateDEI': lst.append( str(c1.text))
          if c1.tag==crp+'NumberOfEmployees':
              if c1.get('contextRef')=='CurrentYearInstant':
                 lst.append( str(c1.text) )
              if c1.get('contextRef')=='CurrentYearInstant_NonConsolidatedMember':
                 lst.append( str(c1.text) )
          if c1.tag==crp+'InformationAboutEmployeesTextBlock':
               #ff = open( xbrl_path , "r" ,encoding="utf-8" ).read() 
              soup = BeautifulSoup(  str(c1.text) ,"html.parser")
              x = soup.find_all("table" )
              lst.append( str(x) )

    lst.append( pdf ) 
    lst.append( sic )       
    df = pd.DataFrame(lst)
    df2 = df.iloc[::-1].T
    df2.iloc[:,0:10 ].to_csv('test.csv', mode='a', header=False)

2.整形処理

2-1. pandasのread_html()のパラメーター「error_bad_lines」について

 前回はpandasのread_html()を利用して従業員の表テーブルを抜きだそうと試みたが、今回はbeautiful Soupを使ってfind_all()でtable句の箇所を抜き出して、tdタグを逐次ループして判定しながら値を抜き出している。

 上記1.でxbrlファイルから抜き取ったデータを格納したcsvファイルをpandasのread_csv()で読み込んでるのだが、エラーで読めなかった該当銘柄の該当年度の有報の従業員のタグの文字列がどうしても発生してしまうので、その行は苦し紛れに「error_bad_lines=False」をつけて回避している。(注:抜けた該当銘柄の該当年度の有報の従業員は、後で有報キャッチャーのページでpdfを目視で閲覧して数値を取得...泥臭いorz)

cleanse.py

import pandas as pd

import requests
from bs4 import BeautifulSoup
import unicodedata
import re
  
fn = 'test.csv'
df = pd.read_csv(fn , header=None, error_bad_lines=False)

def fn_cleanse_emp(k):
      
      lst , i = [] , 0 
      
      txt=df.iloc[k,3]
      sic , ecode =df.iloc[k,1] , df.iloc[k,5]
      ed , adrs =df.iloc[k,4] , df.iloc[k,6].replace('\n','_')
      emp_nc , emp_c =df.iloc[k,7] , df.iloc[k,8]
      #emp_nc =df.iloc[k,7]
      pdf=df.iloc[k,2]
      
      soup = BeautifulSoup( txt ,"html.parser")
      tbls = soup.find_all("table")
      for tbl in tbls:
            if str(tbl).find('給与')>-1:
                tds = tbl.find_all("td")

      l=len(tds)
      for td in tds: 
            if str(td).find('給与')>-1:
                u = 8 if l-i >=8 else l-i 
                for j in range(1,u):
                      f1 = tds[i+j].text
                      if f1.find('\n')>-1: f1=f1.replace('\n','')
                      if f1.find('\u3000')>-1: f1=f1.replace('\u3000','')
                      if f1.find('\xa0')>-1: f1=f1.replace('\xa0','')
                      if f1.find(',')>-1: f1=f1.replace(',','')
                      #print(f1 )
                      lst.append(f1)   
                f1=td.text.split('給与')[1]
                f1=f1.replace('\n','')
                lst.append( f1 )
                        
            i=i+1
      
      
      lst=list(reversed(lst))
      print(k, ',',sic, ',',ed , ',',ecode, ',',lst, ',',emp_nc, ',',emp_c, ',',adrs, ',',pdf)
      
  # main
if __name__ == '__main__':

    st,ed=0,len(df) #rec_id
    #fn_cleanse_emp( 0 ) 
    x=[fn_cleanse_emp(k) for k in range(st ,ed)] 

2-2.「平均年間給与」の記載されているテーブル表の特定と値の取得

 上記1.で有価証券報告書の「従業員の状況」のページの表テーブルをbeautiful Soupのfind_all()で抜き取っているので、まず最初に「従業員の状況」のページに複数のテーブルが存在するケースを想定して、該当の従業員数や平均年齢、平均勤続年数、平均年間給与が記載のあるテーブルを「(平均年間)給与」の文字が含まれているか否かで特定している。また同時に「平均年間給与」の文言には、平均年間給与の単位、「(円)」や「(千円)」(まれに「(百万円)」)の記載がされているので、td内の文字列を忘れずに取得している。

 次に「平均年間給与」の文言が含まれているtdタグが、tableタグの最初のtdタグから何番目かを取得して、それ以降の残りのtdタグが何個残っているか数を特定して、tdタグの数の分だけループさせてtdタグの値を逐次取得している。つまりテーブルのヘッダー「平均年間給与(円)」の次の行(HTMLで言うと次のtrタグのtdタグ群)に目的の値である平均年収や平均年齢、平均勤続年数がセットされているであろうと仮定している。もっと言うと表テーブルのTDタグの値が左から「(1)単独従業員数、(2)単独平均年齢(歳)、(3)単独平均勤続年数、(4)平均年間給与(円)」の順番で並び、最後のtdタグが平均年間給与であると想定して上記のスクリプトが記述されている。なのでリストlistにappend()で順番に格納した後に、reversed()でリストの格納順番を逆にして、平均年間給与が一番右に来るようにしている。

2-3.reversed()でリストの格納順番を逆にする理由

 単独従業員数の箇所に臨時従業員の数を併記している会社が、単独従業員数の右に記載していたり下に改行して記載していたりしてバラバラで、tdの数(カンマの数)が定まらないので、格納したリストをリバースして右に一番取得したい平均年間給与が来るようにしている。見た感じどの会社も、単独従業員数以外の項目、「(2)単独平均年齢(歳)、(3)単独平均勤続年数、(4)平均年間給与(円)」の順番は統一されているような感じだったので、リバースさせて「(4)平均年間給与(円)、(3)単独平均勤続年数、(2)単独平均年齢(歳)」の順に揃うようにしている。

 上記スクリプトを実行して、有価証券報告書での臨時従業員数の記載を観察してみると、大体記載パターンは以下の3通りある模様↓

■パターン1:臨時従業員が単独従業員と同じtdタグで記載される

単独従業員数 平均年齢(歳) 平均勤続年数(年) 平均年間給与(千円)
100 [600] 40.2 10.3 5000
→ 一番オーソドックスな記載の仕方。平均年間給与が一番右のtdタグに位置している。

■パターン2:臨時従業員が単独従業員の次のtdタグで記載される

単独従業員数 平均年齢(歳) 平均勤続年数(年) 平均年間給与(千円)
100 [600] 40.2 10.3 5000

→ pdfでの見た目はパターン1と同じだが、HTMLの記述は単独従業員の次のtdタグで記載されている。このパターンも平均年間給与が一番右のtdタグに位置している。

■パターン3:臨時従業員が単独従業員の下の行のtdタグで記載されている

単独従業員数 平均年齢(歳) 平均勤続年数(年) 平均年間給与(千円)
100 40.2   10.3            5000
[600]
→ pdfでの見た目だと、単独従業員数の同一セルで単独従業員数の改行した位置に記載があるが、HTMLの記述が単独従業員数から平均年間給与のtrタグの次のtrタグのtdタグ、つまり平均年間給与がセットされたtdタグの次のtdタグに臨時従業員数がセットされている。

■パターン1〜3の取得結果のリストを左右逆転させる

f1: f2: f3: f4: f5: ※memo
5000 10.3 40.2 100[600] ※パターン1:成功
5000 10.3 40.2 100  [600] ※パターン2:成功
[600] 5000 10.3 40.2 100  ※パターン3:失敗

これらのパターンを踏まえて、リストを左右逆転させて右から「f1:平均年間給与(円)、f2:単独平均勤続年数、f3:単独平均年齢(歳)」の3列を取得できるようにしている。ただしパターン3をリバースすると、「(5)臨時従業員数、平均年間給与(円)、単独平均勤続年数、単独平均年齢(歳)」となってしまうので、これは改良の余地がある。

 なお従業員数(連結と単独)は、上記のような記載の癖があるので、従業員の表テーブルからの取得は諦めて、別のページの過去数年の経営成績の表から、つまりXBRLタグ「jpcrp_cor:NumberOfEmployees」で別途取得している。なので、上記スクリプトでf4:単独従業員数やf5:臨時従業員数は後で削除することを想定している。

3.上記スクリプトの実行結果

以下は、証券コードの6000番台を取得、整形した結果のイメージ(抜粋)↓
20190120 1.37.11.png

 上のイメージ図でいうとid=15の証券コード6023の2018年3月期有報のレコードが、臨時従業員が平均年間給与よりも右に記載されているパターン(上述のパターン3のケース、上図ではリストを左右逆転させているので平均年間給与よりも左に臨時従業員数が位置している)なので、後で削除するなどのケアが必要。

 なお上記1.のXBRLファイルからのタグの中身を取得してcsvファイルを生成するのが、1銘柄(3期分の有報)の読み込みが大体約15秒〜20秒前後かかっているので、2019年1月時点で約3600社の上場会社があると想定すると...まあそれなりに処理時間はかかる見込みだが、前半のXBRLの読み込みはほとんどエラーで引っかからなくなったので、実行してあとは処理が終わるまで放置できるかと。(自分はGoogle Colaboratory上で実行しているので、90分のセッション切れにならないように、たまに様子を見てはいるが、まあ放置できるかと思う)

 一応、証券コードで区切って、例えば6000から6500まで500社ずつに細切れにしてcsvを作成するように自分はしている。

 上記2.の整形処理は、処理は時間がかからないが、ちょいちょいエラーで処理が止まる。けれどもざっくりと整形して値が抽出できているっぽい、結構いい線行ってるのではないかと思う。しかしまだ完全に整形しきれた完成形ではなく、ところどころおかしな箇所はあったり欠損値が散見されるので、追加でさらにクレンジング作業は必要。

...ということで、その3に続きます。

1
3
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
1
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?