R言語に負けるのは悔しいので、Go言語でも生存曲線を描いてみた

> R言語もすなる生存分析といふものをGo言語もしてみむとするなり。

R言語やPythonで作ると簡単にできるのですが、Goで作ってるもので統計データを元に自動判定させてなんかゴニョゴニョしてみたいときにどうやるんだろうということでやってみました。

# 設計

[Exploratory](https://ja.exploratory.io/)で作ったものがあるので、これをベースにします。

![スクリーンショット 2019-12-15 20.57.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9094/9fdd5fb1-43d8-50c6-c459-c8633f923ad4.png)

ステップを見ると

1. アクセスログのデータ読み込む
1. 時系列で並べ替える
1. ユーザでグルーピングする
1. 集計する
1. ユーザがもう辞めているかどうかを判定できるようにする
1. 計算する

なるほどね... 
![understand.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9094/3d922896-096b-2a9e-6d31-dd400349abda.jpeg)

# つくったもの

https://github.com/usk81/til/tree/master/go/survivor_analysis

## 使ったパッケージ

- github.com/gocarina/gocsv
    - CSVを読み込んで、Structureに設定してくれる。
    - 標準パッケージで同じ事やったら面倒
- github.com/usk81/tiff
    - [自作](https://qiita.com/usk81/items/a0987ffc5e388bdf3e65)
    - 2つの時間差を計算してくれるパッケージ
- gonum.org/v1/plot
    - Plotのデータを渡すとグラフを作って画像出力してくれます

## 使ったデータ

[データサイエンティストブートキャンプ](https://ja.exploratory.io/training-jp)に参加したときに使ったデータを使いました。
使ったデータは作ったプログラムと一緒にgithubに上げています

- survival_access_log.csv
  - ユーザのアクセスログ
- survival_canceled_users.csv
  - ユーザが退会してるかどうかの判定

## 実際のプログラム

```go:datetime.go
package main

import "time"

type DateTime struct {
	time.Time
}

// MarshalCSV Converts the internal date as CSV string
func (date *DateTime) MarshalCSV() (string, error) {
	return date.Time.Format(time.RFC3339), nil
}

// You could also use the standard Stringer interface
func (date *DateTime) String() string {
	return date.String() // Redundant, just for example
}

// UnmarshalCSV Converts the CSV string as internal date
func (date *DateTime) UnmarshalCSV(csv string) (err error) {
	date.Time, err = time.Parse(time.RFC3339, csv)
	return err
}
```

```go:main.go
package main

import (
	"fmt"
	"image/color"
	"math/rand"
	"os"
	"time"

	"github.com/gocarina/gocsv"
	"github.com/usk81/tiff"
	"gonum.org/v1/plot"
	"gonum.org/v1/plot/plotter"
	"gonum.org/v1/plot/vg"
	"gonum.org/v1/plot/vg/draw"
)

type accessLog struct {
	Timestamp DateTime `csv:"timestamp"`
	UserID    string   `csv:"userid"`
	OS        string   `csv:"os"`
	Contry    string   `csv:"contry"`
}

type user struct {
	UserID   string `csv:"userid"`
	Canceled string `csv:"canceled"`
}

type data struct {
	UserID    string
	FirstDate time.Time
	LastDate  time.Time
	JoinMonth string
	Months    int
	Canceled  bool
}

type summary struct {
	Total int
	Data  []int
}

type monthly struct {
	JoinDate time.Time
}

const location = "Asia/Tokyo"

func init() {
	loc, err := time.LoadLocation(location)
	if err != nil {
		loc = time.FixedZone(location, 9*60*60)
	}
	time.Local = loc
}

func main() {
	// load survival_access_log
	lg := []*accessLog{}
	if err := loadCSVFile("survival_access_log.csv", &lg); err != nil {
		panic(err)
	}

	us := []*user{}
	if err := loadCSVFile("survival_canceled_users.csv", &us); err != nil {
		panic(err)
	}

	// group by user
	groupedLogs := map[string]data{}
	for _, v := range lg {
		if v == nil {
			continue
		}
		tt := time.Date(
			v.Timestamp.Time.Year(),
			v.Timestamp.Time.Month(),
			1,
			0,
			0,
			0,
			0,
			time.Local,
		)
		g, ok := groupedLogs[v.UserID]
		if ok {
			if g.FirstDate.After(tt) {
				g.FirstDate = tt
				g.JoinMonth = tt.Format("2006-01")
				g.Months = tiff.New(g.FirstDate, g.LastDate).Months()
			} else if g.LastDate.Before(tt) {
				g.LastDate = tt
				g.Months = tiff.New(g.FirstDate, g.LastDate).Months()
			}
		} else {
			// create summary data
			g = data{
				UserID:    v.UserID,
				FirstDate: tt,
				LastDate:  tt,
				JoinMonth: tt.Format("2006-01"),
				Months:    0,
			}

			// join survival_canceled_users
			for _, u := range us {
				if u != nil && u.UserID == v.UserID {
					g.Canceled = (u.Canceled == "TRUE")
					break
				}
			}
		}
		groupedLogs[v.UserID] = g
	}

	// group by join date
	summaries := map[string]summary{}
	for _, l := range groupedLogs {
		s, ok := summaries[l.JoinMonth]
		if ok {
			d := s.Data
			for i := 0; i <= l.Months; i++ {
				if len(d)-1 < i {
					d = append(d, 1)
				} else {
					d[i]++
				}
			}
			s.Data = d
		} else {
			d := []int{}
			for i := 0; i <= l.Months; i++ {
				fmt.Printf("%s : %s\n", l.FirstDate.Format("2006-01"), l.LastDate.Format("2006-01"))
				if l.FirstDate.Format("2006-01") == l.LastDate.Format("2006-01") {
					d = append(d, 0)
				} else {
					d = append(d, 1)
				}
			}
			s.Data = d
		}
		s.Total++
		summaries[l.JoinMonth] = s
	}

	// Create Plot
	p, err := plot.New()
	if err != nil {
		panic(err)
	}

	p.Title.Text = "Survival Analysis"
	p.X.Label.Text = "Time"
	p.Y.Label.Text = "Survival Rate (%)"

	for k, s := range summaries {
		pts := make(plotter.XYs, len(s.Data))
		ft := float64(s.Total)
		for i, d := range s.Data {
			pts[i].X = float64(i)
			pts[i].Y = (float64(d) / ft) * 100

		}
		rand.Seed(time.Now().UnixNano())
		ri := rand.Intn(255)
		ll, lp, _ := plotter.NewLinePoints(pts)
		rbga := color.RGBA{R: uint8(255 - ri), B: uint8(128 - ri), A: uint8(ri)}
		ll.Color = rbga
		lp.Shape = draw.CircleGlyph{}
		lp.Color = rbga
		p.Add(ll, lp)
		p.Legend.Add(k, ll, lp)
	}

	// Save the plot to a PNG file.
	if err = p.Save(10*vg.Inch, 10*vg.Inch, "points.png"); err != nil {
		panic(err)
	}
}

func loadCSVFile(fp string, v interface{}) (err error) {
	f, err := os.OpenFile(fp, os.O_RDWR|os.O_CREATE, os.ModePerm)
	if err != nil {
		return
	}
	defer f.Close()
	return gocsv.UnmarshalFile(f, v)
}
```

## 結果

なんか違うｗ
多分、0ヶ月目の計算がうまく行ってないのと、計算方法が少し違いそうです。
でも生存曲線はGoでも描けそうなことはわかりました

![スクリーンショット 2019-12-15 20.57.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9094/9fdd5fb1-43d8-50c6-c459-c8633f923ad4.png)

![plot.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9094/01e2c5db-5ade-b64d-b113-d302f6a0b9ed.png)

## 後記

よっぽどのことがない限りは今のところはGoで統計分析っぽいことはやめたほうがよさそう。
とても大変です