TensorBoard简介

TensorBoard

这是对《Tensorflow实战google深度学习框架》整理的学习笔记。

一、TensorBoard的基础知识

TensorBoard是Tensorflow的可视化工具,它可以通过Tensorflow程序运行过程中输出的日志文件可视化Tensorflow程序的运行状态。TensorBoard和Tensorflow程序跑在不同的进程中,TensorBoard会自动读取最新的TensorFlow日志文件,并呈现当前TensorFlow程序运行的最新状态。
以下代码展示了一个简单的TensorFlow程序,在这个程序中完成了TensorBoard日志输出功能

import tensorflow as tf

# 定义一个简单的计算图,实现向量加法操作
input1 = tf.constant([1.0, 2.0, 3.0], name="input1")
input2 = tf.Variable(tf.random_uniform([3]), name="input2")
output = tf.add_n([input1, input2], name="add")

# 生成一个写日志的writer,并将当前的TensorFlow计算图写入日志。
# TensorFlow提供多种写日志文件API
writer = tf.summary.FileWriter("test_log", tf.get_default_graph())
writer.close()

运行下面的命令可以启动TensorBoard.

# 运行TensorBoard,并将日志的地址指向上面程序输出的地址
tensorboard --logdir=./test_log

运行上面的命令会启动一个服务,这个父母的端口默认为6006。通过浏览器打开localhost:6006。使用--port参数可以改变启动服务的端口。
打开TensorBoard如下:

TensorBoard简单的日志输出

二、TensorFlow计算图可视化

  • 2.1、命令空间与Tensorflow图上节点的关系

为了更好的组织可视化效果图中的计算节点,TensorBoard支持通过TensorFlow命名空间来整理可视化效果图上的节点。在TensorBoard的默认视图中,TensorFlow计算图中同一个命名空间中所有节点会被缩略成一个节点,只有顶层命名空间中的节点才会被显示在TensorBoard可视化效果图上。
除了tf.variable_scope函数可以管理命名空间外,tf.name_scope函数也提供了命名空间管理功能。这两个函数在大部分情况下是等价的,唯一的区别在于tf.get_varibale函数。
下例展示了这两个函数的区别:

import tensorflow as tf

# 建立命名空间"foo"
with tf.variable_scope("foo"):
    a = tf.get_variable("bar", [1]) 
    print a.name    # 输出:foo/bar:0
    
# 建立命名空间"bar"
with tf.variable_scope("bar"):
    b = tf.get_variable("bar", [1])
    print b.name    # 输出:bar/bar:0
    
# 建立命名空间"a"
with tf.name_scope("a"):
    # 使用tf.Variable函数生成变量会受到tf.name_scope影响,
    # 于是这个变量的名称为"a/Variable"
    a = tf.Variable([1])
    print a.name    # 输出a/Variable:0
    
    # tf.get_variable函数不受tf.name_scope函数的影响
    # 于是变量并不在a这个命名空间中
    a = tf.get_variable("b", [1])
    print a.name    # 输出的是b:0
    
# 建立命名空间"b"
with tf.name_scope("b"):
    # 因为tf.get_variable不受tf.name_scope函数影响,所以这里将试图
    # 获取名称为"a"的变量。然而,这个变量已经被申明,于是这里会报重
    # 复申明的错误
    tf.get_variable("b", [1])

通过对命名空间的管理,修改上述代码:

import tensorflow as tf

# 将输入定义放入各自的命名空间,从而使得TensorBoard可以根据命名空间
# 来整理可视化效果图上的节点
with tf.name_scope("input1"):
    input1 = tf.constant([1.0, 2.0, 3.0], name="input1")

with tf.name_scope("input2"):
    input2 = tf.Variable(tf.random_uniform([3]), name="input2")

output = tf.add_n([input1, input2], name="add")

writer = tf.summary.FileWriter("./test_log", tf.get_default_graph())
writer.close()

结果如下:

利用命名空间显示计算图

下例将给出一个样例来展示如何很好的可视化一个真实的神经网络结构图:
mnist_inference.py文件,主要用于定义神经网络节点个数、获取各层权重、神经网络的前向计算。

# mnist-inference.py
import tensorflow as tf

INPUT_NODE = 784     # 输入层节点
OUTPUT_NODE = 10     # 输出层节点
LAYER1_NODE = 500    # 隐含层节点

def get_weight_variable(shape, regularizer):
    '''
    函数意义:
        获取指定shape的权重数据,并根据regularizer
    确定是否把该权重加入正则损失集合
    参数意义:
        shape:网络连接层结构
        regularizer:正则化方法
    返回值:
        返回获取的权重数据
    '''
    weights = tf.get_variable("weights", shape, initializer=tf.truncated_normal_initializer(stddev=0.1))
    if regularizer != None: 
        tf.add_to_collection('losses', regularizer(weights))
    return weights


def inference(input_tensor, regularizer):
    '''
    函数意义:
        计算神经网络的前向传播
    参数意义:
        input_tensor:输入数据的tensor
        regularizer:正则化方法
    返回值:
        返回前向传播结果数据,注意此结果在最后一层是未经过激活的
    '''
    with tf.variable_scope('layer1'):
        # 建立命名空间'layer1'
        weights = get_weight_variable([INPUT_NODE, LAYER1_NODE], regularizer)
        biases = tf.get_variable("biases", [LAYER1_NODE], initializer=tf.constant_initializer(0.0))
        layer1 = tf.nn.relu(tf.matmul(input_tensor, weights) + biases)

    with tf.variable_scope('layer2'):
        # 建立命名空间'layer2'
        weights = get_weight_variable([LAYER1_NODE, OUTPUT_NODE], regularizer)
        biases = tf.get_variable("biases", [OUTPUT_NODE], initializer=tf.constant_initializer(0.0))
        layer2 = tf.matmul(layer1, weights) + biases
    
    # 返回前向传播计算结果
    return layer2

mnist_train.py文件展示了可视化一个真实的神经网络结构图。

#!/usr/bin/env python2
# -*- coding: utf-8 -*-
"""
Created on Thu Aug 10 22:06:32 2017

@author: zhengbiao
"""

import tensorflow as tf
from tensorflow.examples.tutorials.mnist import input_data
import mnist_inference
import sys
reload(sys)
sys.setdefaultencoding('utf8')

# 定义神经网络的参数
BATCH_SIZE = 100                # Batch_Size的大小
LEARNING_RATE_BASE = 0.8        # 初始学习率
LEARNING_RATE_DECAY = 0.99      # 学习率衰减系数
REGULARIZATION_RATE = 0.0001    # 正则化率
TRAING_STEPS = 3000             # 迭代总步数
MOVING_AVERAGE_DECAY = 0.99     # 滑动平均率


def train(mnist):
    #  输入数据的命名空间。
    with tf.name_scope('input'):
        x = tf.placeholder(tf.float32, 
                           [None, mnist_inference.INPUT_NODE], 
                           name='x-input')
        y_ = tf.placeholder(tf.float32, 
                            [None, mnist_inference.OUTPUT_NODE], 
                            name='y-input')
    regularizer = tf.contrib.layers.l2_regularizer(REGULARIZATION_RATE)
    y = mnist_inference.inference(x, regularizer)
    global_step = tf.Variable(0, trainable=False)
    
    # 处理滑动平均的命名空间。
    with tf.name_scope("moving_average"):
        variable_averages = tf.train.ExponentialMovingAverage(MOVING_AVERAGE_DECAY, global_step)
        variables_averages_op = variable_averages.apply(tf.trainable_variables())
   
    # 计算损失函数的命名空间。
    with tf.name_scope("loss_function"):
        cross_entropy = tf.nn.sparse_softmax_cross_entropy_with_logits(logits=y, labels=tf.argmax(y_, 1))
        cross_entropy_mean = tf.reduce_mean(cross_entropy)
        loss = cross_entropy_mean + tf.add_n(tf.get_collection('losses'))
    
    # 定义学习率、优化方法及每一轮执行训练的操作的命名空间。
    with tf.name_scope("train_step"):
        learning_rate = tf.train.exponential_decay(
            LEARNING_RATE_BASE,
            global_step,
            mnist.train.num_examples / BATCH_SIZE, LEARNING_RATE_DECAY,
            staircase=True)
        
        train_step = tf.train.GradientDescentOptimizer(learning_rate).minimize(loss, global_step=global_step)
        
        with tf.control_dependencies([train_step, variables_averages_op]):
            train_op = tf.no_op(name='train')
            
    # 训练模型。
    with tf.Session() as sess:
        tf.global_variables_initializer().run()
        writer = tf.summary.FileWriter("./modified_mnist_train.log", tf.get_default_graph())
        
        for i in range(TRAING_STEPS):
            xs, ys = mnist.train.next_batch(BATCH_SIZE)

            if i % 1000 == 0:
                # 配置运行时需要记录的信息。
                run_options = tf.RunOptions(trace_level=tf.RunOptions.FULL_TRACE)
                # 运行时记录运行信息的proto。
                run_metadata = tf.RunMetadata()
                _, loss_value, step = sess.run(
                    [train_op, loss, global_step], feed_dict={x: xs, y_: ys},
                    options=run_options, run_metadata=run_metadata)
                # 将节点在运行时的信息写入日志文件中
                writer.add_run_metadata(run_metadata, "step%03d" % i)
                print "After %d training step(s), loss on training batch is %g." % (step, loss_value)
            else:
                _, loss_value, step = sess.run([train_op, loss, global_step], feed_dict={x: xs, y_: ys})
        writer.close()
    
def main(argv=None): 
    mnist = input_data.read_data_sets("../datasets/MNIST_data", one_hot=True)
    train(mnist)

if __name__ == '__main__':
    main()

其中需要解释的几个函数如下:

with tf.control_dependencies([train_step, variables_averages_op]):
            train_op = tf.no_op(name='train')
上述函数的作用指定了更新参数滑动平均值的操作和通过反向传播更新变量的操作同时进行。
函数tf.control_dependencies()的意义如下:
函数原型:
    tf.control_dependencies(control_inputs)
函数意义:
    使用the default graph对Graph.control_dependencies()函数的包装
参数意义:
    control_inputs:A list of Operation or Tensor Object.
        
函数tf.Graph.control_dependencies()意义
函数原型:
    tf.Graph.control_dependencies(control_inputs)
函数意义:
    返回一个上下文管理器,这个上下文管理器指定了控制的依赖关系
例子:
with g.control_dependencies([a, b, c]):
  # `d` and `e` will only run after `a`, `b`, and `c` have executed.
  d = ...
  e = ...

函数tf.no_op()
函数原型:
    tf.no_op(name=None)
函数意义:
    Does nothing. Only useful as a placeholder for control edges
函数参数:
    A name for the operation (optional)
返回值:
    The created Operation

TensorBoard截图如下:

神经网络结构可视化

TensorBoard可以很好的展示整个神经网络的结构。
如上图所示,input节点代表了神经网络需要的输入数据;input节点的数据传输给layer1节点;layer1节点的数据传输给layer2节点,经过layer2的计算得到前向传播的结果。loss_function节点表示计算损失函数的过程,这个过程既依赖前向传播的结果来计算交叉熵(layer2到loss_function),又依赖于每一层所定义的变量来计算L2正则化损失(layer1和layer2到loss_function的边)。loss_function的计算结果会提供给神经网络的优化过程,也就是图中train_step所代表的节点。
效果图上的粗细表示的是两个节点之间传输的标量维度的总大小,而不是传输的标量个数(其实不太理解)。
效果图上的虚边表示计算之间的依赖关系,通过tf.control_dependencies函数指定了更新参数滑动平均值的操作和通过反向传播更新变量的操作需要同时进行,于是moving_average与train_step之间存在一条虚边。
TensorBoard将TensorFlow计算图分成主图(Main Graph)和辅助图(Auxiliary nodes)两个部分来呈现。TensorBoard会自动将连接比较多的节点放在辅助图中,使得主图的结构更加清晰。
TensorBoard不仅支持自动调整的方式,也支持手动调整的方式。

  • 2.2、Tensorflow图上节点的可视化信息
    TensorBoard还可以展示TensorFlow计算图上每个节点的基本信息以及运行时消耗的时间和空间。
    以下代码将不同迭代轮数时每一个TensorFlow计算节点的运行时间和消耗的内存写入TensorFlow的日志文件。
with tf.Session() as sess:
        tf.global_variables_initializer().run()
        writer = tf.summary.FileWriter("./modified_mnist_train.log", tf.get_default_graph())
        
        for i in range(TRAING_STEPS):
            xs, ys = mnist.train.next_batch(BATCH_SIZE)

            if i % 1000 == 0:
                # 配置运行时需要记录的信息。
                run_options = tf.RunOptions(trace_level=tf.RunOptions.FULL_TRACE)
                # 运行时记录运行信息的proto。
                run_metadata = tf.RunMetadata()
                _, loss_value, step = sess.run(
                    [train_op, loss, global_step], feed_dict={x: xs, y_: ys},
                    options=run_options, run_metadata=run_metadata)
                # 将节点在运行时的信息写入日志文件中
                writer.add_run_metadata(run_metadata, "step%03d" % i)
                print "After %d training step(s), loss on training batch is %g." % (step, loss_value)
            else:
                _, loss_value, step = sess.run([train_op, loss, global_step], feed_dict={x: xs, y_: ys})
        writer.close()

在TensorBoard的GRAPHS中,在页面Session runs选项中会出现一个下来菜单,这个下来菜单会显示所有通过writer.add_run_metadata函数记录的运行数据。下图显示的是,运行次数为2000次,各个节点所消耗的运行时间

显示节点信息

在TensorBoard界面左侧的Color栏中,除了Computer time和Memory外,还有Structure和Device两个选项。
其中,展示的可视化效果图都是使用默认的Structure选项。在这个试视图中,灰色的节点表示没有其他节点和它拥有相同的结构。如果有两个节点的结果相同,他们会涂上相同的颜色。Device选项表示运算的设备,在使用GPU时,可以通过这种方式直观的看哪些节点被放到了那个GPU上或者CPU上。

TensorBoard上各项监控指标

TensorBoard除了可以可视化TensorFlow的计算图,还可以可视化EVENTS、IMAGES、AUDIO和HISTOGRAM等栏目。

监控指标列表

可视化各项监控指标的流程:

SUMMARY_DIR = "log"     # 日志路径
TRAINS_STEPS = 3000     # 训练的总步数

# 1.添加生成日志操作
tf.summary.scalar(name_scalar, scalar)
tf.summary.image(name_image, image_shape_input, image_num)
tf.summary.histogram(name_his, tensor)

# 2.统一日志生成操作
merged = tf.summary.merge_all()

with tf.Session() as sess:
    # 3.初始化写日志类
    summary_writer = tf.summary.FileWriter(SUMMARY_DIR, sess.graph)
    for i in xrange(TRAIN_STEPS):
        summary, _ = sess.run([merged, train_step], feed_dict={x: xs, y_:ys})
        # 4.将所有日志写入日志文件
        summary_writer.add_summary(summary, i)
    # 5.关闭日志文件
    summary_writer.close()

以下代码展示了神经网络训练mnist训练集时监控指标可视化。

import tensorflow as tf
from tensorflow.examples.tutorials.mnist import input_data

SUMMARY_DIR = "supervisor.log"
BATCH_SIZE = 100
TRAIN_STEPS = 3000

# 生成Tensor var的统计信息、均值信息和均方差信息
def variable_summaries(var, name):
    # 通过tf.summary.histogram函数记录张量var中元素取值分布。
    # tf.summary.histogram函数会生成一个Summary protocol buffer.
    # 将Summary写入TensorBoard日志文件之后,可以在HISTOGRAM栏下看到对应名称的
    # 图表
    # tf.summary.histogram函数不会立刻执行,只有当sess.run函数明确调用这个操
    # 作,TensorFlow才会真正生成并输出Summary protocol buffer
    tf.summary.histogram(name, var)

    # 计算张量var的平均值,并定义生成平均值信息日志操作
    mean = tf.reduce_mean(var)
    tf.summary.scalar(name+'/mean', mean)

    # 计算张量var的标准差, 并定义生成其日志的操作
    stddev = tf.sqrt(tf.reduce_mean(tf.square(var-mean)))
    tf.summary.scalar(name+'/stddev', stddev)
        
# 定义生成一层全连接层的神经网络
def nn_layer(input_tensor, input_dim, output_dim, layer_name, act=tf.nn.relu):
    # 将同一层神经网络放在同一个命名空间下
    with tf.name_scope(layer_name):
        # 申明神经网络边上的权重weights,并生成weights的监控项目
        weights = tf.Variable(tf.truncated_normal([input_dim, output_dim], stddev=0.1))
        variable_summaries(weights, 'weights')
            
        # 定义biases、并生成biases的监控项目
        biases = tf.Variable(tf.constant(0.0, shape=[output_dim]))
        variable_summaries(biases, 'biases')
            
        # 定义激活前的值、并生成激活前的值的监控项目
        preactivate = tf.matmul(input_tensor, weights) + biases
        tf.summary.histogram('pre_activations', preactivate)
        # 计算preactivate的激活值
        activations = act(preactivate, name='activation')
        
        # 记录神经网络节点输出在经过激活函数之后的分布
        tf.summary.histogram('activations', activations)
        return activations
    
# 定义main函数
def main():
    # 下载并加载MNIST_DATA,并one_hot化
    mnist = input_data.read_data_sets("../datasets/MNIST_data", one_hot=True)
    
    # 定义输入值x, y_
    with tf.name_scope('input'):
        x = tf.placeholder(tf.float32, [None, 784], name='x-input')
        y_ = tf.placeholder(tf.float32, [None, 10], name='y-input')
        
    # 生成image监控
    with tf.name_scope('input_reshape'):
        image_shaped_input = tf.reshape(x, [-1, 28, 28, 1])
        tf.summary.image('input', image_shaped_input, 10)
        
    # 计算前向传播结果
    hidden1 = nn_layer(x, 784, 500, 'layer1')
    y = nn_layer(hidden1, 500, 10, 'layer2', act=tf.identity)# 计算前向传播结果
    '''
    tf.identity(input, name=None)
    返回一个与输入张量或值相同的张量和内容
    Args:
        input: A Tensor.
        name: A name for the operation(optional)
    Returns:
        A Tensor. Has the same type as input.
    '''
    
    # 计算交叉熵、并生成交叉熵的监控项目
    with tf.name_scope('cross_entropy'):
        cross_entropy = tf.reduce_mean(tf.nn.softmax_cross_entropy_with_logits(logits=y, labels=y_))
        tf.summary.scalar('cross_entropy', cross_entropy)
        
    # 定义优化操作
    with tf.name_scope('train'):
        train_step = tf.train.AdamOptimizer(0.001).minimize(cross_entropy)
        
    # 定义模型在当前给定的数据上的正确率,并定义生成正确率的监控项目。
    # 如果在sess.run时给定的数据是训练的batch,那么得到的准确率就是在这个训练batch上的正确率;
    # 如果给定的数据为验证或者测试数据,那么得到的正确率就是当前模型在验证或测试集数据上的正确率。
    with tf.name_scope('accuracy'):
        with tf.name_scope('correct_prediction'):
            correct_prediction = tf.equal(tf.argmax(y, 1), tf.argmax(y_, 1))
            '''
            tf.equal(x, y, name=None)
                Args:
                    x,y: all are Tensor.
            returns:
                Returns the truth value of (x == y) element-wise
            '''
        with tf.name_scope('accuracy'):
            accuracy = tf.reduce_mean(tf.cast(correct_prediction, tf.float32))
            '''
            tf.cast(x, dtype, name=None)
            Casts a tensor to a new type
            Args:
                x: A Tensor or SparseTensor.
                dtype: The destination type.
                name: A name for the operation(optional)
            returns:
                A Tensor or SparseTensor with sanme shape as x.
            For example:
                # tensor 'a' is [1.8, 2.2], dtype=tf.float32
                tf.cast(a, tf.int32) ==> [1, 2] # dtype=tf.int32
            '''
        tf.summary.scalar('accuracy', accuracy)
        
    # 和tf.summary.scalar、tf.summary.histogram和tf.summary.image类似,这些函数不会立即执行
    # 除非通过sess.run来明确调用这些函数。因为程序中定义的写日志操作比较多,一一调用很麻烦,所以
    # Tensorflow提供了tf.summary.merge_all()整理所有的日志生成操作。在Tensorflow中,只要运行
    # 这个操作就可以将代码中所有定义的日志生成操作执行一遍,从而将所有日志写入文件。
    merged = tf.summary.merge_all()
    
    with tf.Session() as sess:
        # 初始化写日志的writer,并将当前Tensorflow计算图写入日志
        summary_writer = tf.summary.FileWriter(SUMMARY_DIR, sess.graph)
      
        # 初始化所有的变量
        tf.global_variables_initializer().run()

        for i in range(TRAIN_STEPS):
            # 按BATCH_SIZE取出样本数据
            xs, ys = mnist.train.next_batch(BATCH_SIZE)
            # 运行训练步骤以及所有的日志生成操作,得到这次运行的日志。
            summary, _ = sess.run([merged, train_step], feed_dict={x: xs, y_: ys})
            # 将得到的所有日志写入日志文件,
            # 这样TensorBoard程序就可以拿到这次运行所对应的运行信息。
            summary_writer.add_summary(summary, i)
            '''
            add_summary(summary, global_step=None)
            Adds a Summary protocol buffer to the event file
            Ags:
                summary: A Summary protocol buffer, optionally serialized as a string.
                global_step:Number. Optional global step value to record with the summary.
            '''
        summary_writer.close()
    
if __name__ == '__main__':
    main()

显示项目:

显示的项目

可视化结果如下:
SCALAR结果如下:

SCALAR1
SCALAR2

IMAGE的结果如下:只显示最后一步的前四张

IMAGE

DISTRIBUTION结果如下:

DISTRIBUTION

HISTOGRAM结果如下:

HISTOGRAM
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,393评论 5 467
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,790评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,391评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,703评论 1 270
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,613评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,003评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,507评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,158评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,300评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,256评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,274评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,984评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,569评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,662评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,899评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,268评论 2 345
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,840评论 2 339

推荐阅读更多精彩内容